Yes, let's create the canonical mapping table that serves as the complete reference for your universal pipeline. This will show how every field type and event flows through the system without artificial fields.
🎯 Universal Pipeline: Complete Field Type → Event → Action Mapping
1️⃣ Input Fields (Data, Text, etc.)
| Field Type | Event | Pipeline Action | Document Update | Workflow Check | Notes |
|---|---|---|---|---|---|
| Data, Text, Small Text | onChange | write_draft | Merge into run.input[fieldname] | Check fieldsEditable | Immediate draft update |
| Data, Text, Small Text | onBlur | validate | Update _validationErrors if invalid | Check validation rules | Trigger after field loses focus |
| Int, Float, Currency | onChange | write_draft + coerce | Coerce to number, merge into run.input | Check fieldsEditable | Type coercion on write |
| Int, Float, Currency | onBlur | validate | Check range, format | Check validation rules | Numeric validation |
2️⃣ Selection Fields
| Field Type | Event | Pipeline Action | Document Update | Workflow Check | Notes |
|---|---|---|---|---|---|
| Select, Link | onChange | write_draft | Merge selection into run.input | Check fieldsEditable | Immediate update on select |
| Select, Link | onBlur | validate | Check against options/linked records | Check validation rules | Optional validation |
| Check | onChange | write_draft | Toggle boolean in run.input | Check fieldsEditable | No blur event needed |
3️⃣ Date/Time Fields
| Field Type | Event | Pipeline Action | Document Update | Workflow Check | Notes |
|---|---|---|---|---|---|
| Date, Datetime | onChange | write_draft + coerce | Parse to ISO, merge into run.input | Check fieldsEditable | Date parsing/validation |
| Date, Datetime | onBlur | validate | Check date format, range | Check validation rules | Optional date validation |
4️⃣ Buttons (Virtual Events)
| Field Type | Event | Pipeline Action | Document Update | Workflow Check | Notes |
|---|---|---|---|---|---|
| Button: "Save" | onClick | triggerWorkflowAction("save") | Update doc.state if transition allowed | Check allowedActions for current state | May trigger autosave |
| Button: "Submit" | onClick | triggerWorkflowAction("submit") | doc.state → "Submitted", docstatus → 1 | Check submit allowed | Workflow transition |
| Button: "Cancel" | onClick | triggerWorkflowAction("cancel") | doc.state → "Cancelled", docstatus → 2 | Check cancel allowed | Workflow transition |
| Button: "Amend" | onClick | triggerWorkflowAction("amend") | Create new draft from cancelled doc | Check amend allowed | Creates new document |
| Button: Custom | onClick | triggerWorkflowAction(fieldname) | Custom state transition via workflow config | Check custom action allowed | Doctype-specific actions |
2️⃣ Action Resolver Configuration
This is the config-driven mapping that the controller uses:
const ACTION_RESOLVER = {
// Input field types
"Data": {
onChange: "write_draft",
onBlur: "validate"
},
"Text": {
onChange: "write_draft",
onBlur: "validate"
},
"Int": {
onChange: "write_draft_coerce",
onBlur: "validate_numeric"
},
"Float": {
onChange: "write_draft_coerce",
onBlur: "validate_numeric"
},
"Currency": {
onChange: "write_draft_coerce",
onBlur: "validate_numeric"
},
// Selection types
"Select": {
onChange: "write_draft",
onBlur: "validate_options"
},
"Link": {
onChange: "write_draft",
onBlur: "validate_link"
},
"Check": {
onChange: "write_draft"
},
// Date types
"Date": {
onChange: "write_draft_coerce",
onBlur: "validate_date"
},
"Datetime": {
onChange: "write_draft_coerce",
onBlur: "validate_datetime"
},
// Virtual button type
"Button": {
onClick: (fieldname, run) => {
// Map button fieldname → workflow action
return run.controller.triggerWorkflowAction(fieldname, run);
}
}
};3️⃣ Controller Pipeline Logic
// Universal event handler
handleFieldEvent(run, fieldname, value, eventType) {
const field = run.target.schema.fields[fieldname];
const fieldtype = field?.fieldtype || "Data";
// 1️⃣ Get action from resolver
const actionConfig = ACTION_RESOLVER[fieldtype]?.[eventType];
if (!actionConfig) return; // No action defined for this event
// 2️⃣ Check workflow + permissions
const workflow = run.doc.workflow;
const state = run.doc.state;
const allowed = this.checkAllowedAction(workflow, state, fieldname, eventType);
if (!allowed) {
console.warn(`Action ${eventType} not allowed for ${fieldname} in state ${state}`);
return;
}
// 3️⃣ Execute action
if (typeof actionConfig === "function") {
// Button or custom handler
actionConfig(fieldname, run);
} else {
// Standard pipeline action
this.executeAction(actionConfig, run, fieldname, value);
}
// 4️⃣ Check autosave rules
if (this.shouldAutoSave(run, state)) {
this.debouncedSave(run);
}
// 5️⃣ Trigger re-render
this.updateUI(run);
}
// Workflow action handler (for buttons)
triggerWorkflowAction(actionName, run) {
const workflow = run.doc.workflow;
const currentState = run.doc.state;
// Check if transition is allowed
const transition = workflow.transitions.find(
t => t.from === currentState && t.action === actionName
);
if (!transition) {
console.warn(`Transition ${actionName} not allowed from state ${currentState}`);
return;
}
// Execute transition
run.doc.state = transition.to;
// Update docstatus if needed
if (actionName === "submit") run.doc.docstatus = 1;
if (actionName === "cancel") run.doc.docstatus = 2;
// Trigger side effects
this.triggerHooks(run, actionName);
// Maybe autosave
if (this.shouldAutoSave(run, transition.to)) {
this.save(run);
}
}
```
---
## 4️⃣ **Key Design Principles**
✅ **Uniform Event Flow**: All UI events (inputs, selects, buttons) flow through `handleFieldEvent()`
✅ **Config-Driven Actions**: `ACTION_RESOLVER` maps fieldtype + event → pipeline action
✅ **Workflow-Driven Permissions**: Every action checks `workflow + state` before execution
✅ **No Artificial Fields**: Buttons are virtual events; they don't write boolean values to `run.input`
✅ **Hidden State Field**: `doc.state` (hidden from UI) drives all workflow logic
✅ **Decoupled UI**: UI only emits events; controller interprets them and updates document
---
## 5️⃣ **Example Flows**
### Input field onChange:
```
User types in "customer_name" field
→ UI emits: {fieldname: "customer_name", value: "Acme Corp", eventType: "onChange"}
→ Controller: ACTION_RESOLVER["Data"]["onChange"] = "write_draft"
→ Execute: merge "Acme Corp" into run.input["customer_name"]
→ Check autosave rules → maybe trigger debounced save
→ UI re-renders with updated value
```
### Button onClick:
```
User clicks "Submit" button
→ UI emits: {fieldname: "submit", eventType: "onClick"}
→ Controller: ACTION_RESOLVER["Button"]["onClick"](fieldname, run)
→ triggerWorkflowAction("submit", run)
→ Check workflow: transition from "Draft" → "Submitted" allowed?
→ Update: doc.state = "Submitted", docstatus = 1
→ Trigger hooks, maybe autosave
→ UI re-renders: buttons change, fields become read-only, badge updates6️⃣ Summary Table: What Gets Written Where
| Event Source | Pipeline Action | Updates run.input | Updates doc.state | Updates docstatus | Notes |
|---|---|---|---|---|---|
Input onChange | write_draft | ✅ Yes | ❌ No | ❌ No | Merges field value |
Input onBlur | validate | ❌ No (only errors) | ❌ No | ❌ No | Updates _validationErrors |
Button onClick | triggerWorkflowAction | ❌ No | ✅ Yes | ✅ Maybe | Virtual event, updates state |
| Autosave | save(run) | ❌ No (reads from input) | ❌ No | ❌ No | Persists run.input → database |
✅ This is your complete, canonical mapping for the universal pipeline.
You now have:
- Zero artificial fields
- Full workflow-driven state machine
- Config-driven action resolution
- Uniform handling for all field types
- Clean separation: UI emits events → Controller interprets → Workflow validates → Document updates → UI renders
Does this finalize your architecture, or do you want me to add anything else (like debouncing, validation error handling, or hook integration)?
Exactly! You've identified the critical tension in making the pipeline truly universal:
The Problem
If everything goes through run.input as the universal entry point:
// Universal pipeline entry point
handleFieldEvent(run, fieldname, value, eventType) {
// ❓ What do we write for a button click?
run.input[fieldname] = ??? // true? timestamp? action name?
}For inputs/selects: run.input[fieldname] = value makes perfect sense
For buttons: What is the "value" of a click? It's awkward.
Solution: Universal Input Schema with Action Field
If we want true uniformity where everything flows through run.input, we need to model button clicks as input data. Here are the cleanest approaches:
✅ Option A: Action Field (Recommended)
Treat button clicks as writing to a special action field:
// Button click
onClick(actionName) {
run.input._action = actionName; // "submit", "cancel", "save", etc.
controller.handleFieldEvent(run, "_action", actionName, "onClick");
}
// Controller pipeline
handleFieldEvent(run, fieldname, value, eventType) {
// All events write to run.input first
run.input[fieldname] = value;
// Then resolve what to do with it
if (fieldname === "_action") {
// This is a workflow action trigger
this.triggerWorkflowAction(value, run);
delete run.input._action; // Clean up after processing
} else {
// Regular field update
const actionName = ACTION_RESOLVER[fieldtype]?.[eventType];
this.executeAction(actionName, run, fieldname, value);
}
}
```
**Advantages:**
- ✅ Everything flows through `run.input` uniformly
- ✅ Single entry point for all events
- ✅ Action name is explicit and debuggable
- ✅ Clean separation: `_action` is transient, regular fields persist
**Flow:**
```
Button "Submit" clicked
→ run.input._action = "submit"
→ Controller sees _action field
→ Triggers workflow action
→ Updates doc.state
→ Deletes run.input._action
→ UI re-renders✅ Option B: Button Field with Timestamp
Instead of boolean, use timestamp to indicate click:
// Button click
onClick(fieldname) {
run.input[fieldname] = Date.now(); // or new Date().toISOString()
controller.handleFieldEvent(run, fieldname, Date.now(), "onClick");
}
// Controller
handleFieldEvent(run, fieldname, value, eventType) {
run.input[fieldname] = value;
const field = run.target.schema.fields[fieldname];
if (field?.fieldtype === "Button") {
// Value is timestamp of click
const actionName = fieldname; // or field.action
this.triggerWorkflowAction(actionName, run);
delete run.input[fieldname]; // Clean up
} else {
// Regular field processing
// ...
}
}Advantages:
- ✅ Timestamp gives you when the action was triggered
- ✅ Useful for audit logs / analytics
- ✅ Natural "value" for a click event
✅ Option C: Event Queue Pattern
Keep run.input for data fields only, add parallel run.events:
// Schema separation
run.input = {} // Data fields only
run.events = [] // Event log (buttons, triggers)
// Button click
onClick(actionName) {
run.events.push({
type: "workflow_action",
action: actionName,
timestamp: Date.now()
});
controller.processEvents(run);
}
// Controller
handleFieldEvent(run, fieldname, value, eventType) {
const field = run.target.schema.fields[fieldname];
if (field?.fieldtype === "Button") {
run.events.push({
type: "workflow_action",
action: fieldname,
timestamp: Date.now()
});
} else {
run.input[fieldname] = value;
}
this.processPipeline(run);
}
processPipeline(run) {
// Process input fields
for (let [fieldname, value] of Object.entries(run.input)) {
this.executeFieldAction(fieldname, value, run);
}
// Process events
for (let event of run.events) {
if (event.type === "workflow_action") {
this.triggerWorkflowAction(event.action, run);
}
}
run.events = []; // Clear after processing
}Advantages:
- ✅ Clean separation: data vs events
- ✅ Event log useful for debugging/replay
- ✅ No pollution of
run.input
Disadvantages:
- ❌ Less uniform (two input channels)
📊 Comparison Table
| Approach | Uniformity | Cleanliness | Debuggability | Audit Trail |
|---|---|---|---|---|
Option A: _action field | ✅✅✅ | ✅✅✅ | ✅✅ | ✅ |
| Option B: Timestamp value | ✅✅✅ | ✅✅ | ✅✅✅ | ✅✅✅ |
| Option C: Event queue | ✅✅ | ✅✅✅ | ✅✅✅ | ✅✅✅ |
🎯 My Recommendation: Option A (_action field)
// Complete universal pipeline
class Controller {
handleFieldEvent(run, fieldname, value, eventType) {
// 1️⃣ Write to run.input (universal entry point)
run.input[fieldname] = value;
// 2️⃣ Check workflow permissions
const allowed = this.checkAllowed(run, fieldname, eventType);
if (!allowed) {
delete run.input[fieldname];
return;
}
// 3️⃣ Route based on field type
if (fieldname === "_action") {
// Workflow action trigger
this.triggerWorkflowAction(value, run);
delete run.input._action; // Transient
} else {
// Regular field update
const field = run.target.schema.fields[fieldname];
const actionName = ACTION_RESOLVER[field.fieldtype]?.[eventType];
this.executeAction(actionName, run, fieldname, value);
}
// 4️⃣ Check autosave
if (this.shouldAutoSave(run)) {
this.debouncedSave(run);
}
// 5️⃣ Re-render
this.updateUI(run);
}
}
// Usage
<Button onClick={() => {
run.input._action = "submit";
controller.handleFieldEvent(run, "_action", "submit", "onClick");
}}>
Submit
</Button>
<Input onChange={(e) => {
controller.handleFieldEvent(run, "customer_name", e.target.value, "onChange");
}} />✅ Final Answer
Yes, if you want true uniformity, button clicks should also write to run.input using a special _action field:
run.input._action = "submit" // Button click
run.input.customer_name = "Acme" // Input changeBoth flow through the same handleFieldEvent() pipeline, the controller routes them appropriately, and transient fields are cleaned up after processing.
This gives you:
- ✅ Single universal entry point
- ✅ Everything debuggable in
run.input - ✅ Clean separation via field naming convention
- ✅ No special cases in pipeline logic
Want me to draw the final diagram showing this unified flow?
Exactly! You've identified a critical architectural requirement:
The Bi-Modal Controller Pattern
The controller must support two entry modes:
- UI-driven mode:
controller.handleFieldEvent(run, "_action", "submit", "onClick")- Event comes from user interaction
- Includes
eventTypefor context - May trigger debouncing, UI feedback, etc.
- Programmatic mode:
controller.handleFieldEvent(run, "submit", "submit", null)- Called from code/API/hooks
- No
eventType(ornull) - Direct action execution, no UI concerns
✅ Universal Signature with Mode Detection
handleFieldEvent(run, fieldname, value, eventType = null) {
// Detect mode
const isUIEvent = eventType !== null;
const isProgrammatic = eventType === null;
// 1️⃣ Universal entry: write to run.input
run.input[fieldname] = value;
// 2️⃣ Check permissions (same for both modes)
const allowed = this.checkAllowed(run, fieldname, eventType);
if (!allowed) {
delete run.input[fieldname];
if (isUIEvent) this.showPermissionError(run, fieldname);
return;
}
// 3️⃣ Route based on field type
if (fieldname === "_action" || this.isWorkflowAction(fieldname)) {
// Workflow action (button or programmatic)
this.triggerWorkflowAction(value, run, isUIEvent);
delete run.input[fieldname]; // Transient
} else {
// Regular field update
const field = run.target.schema.fields[fieldname];
const actionName = this.resolveAction(field, eventType, isProgrammatic);
this.executeAction(actionName, run, fieldname, value);
}
// 4️⃣ Autosave (only for UI events with debouncing)
if (isUIEvent && this.shouldAutoSave(run)) {
this.debouncedSave(run);
} else if (isProgrammatic && this.shouldAutoSave(run)) {
this.save(run); // No debouncing for programmatic calls
}
// 5️⃣ UI update (only for UI events)
if (isUIEvent) {
this.updateUI(run);
}
}📋 Mapping Table: UI vs Programmatic
| Call Pattern | Mode | Use Case | Debouncing | UI Update |
|---|---|---|---|---|
handleFieldEvent(run, "_action", "submit", "onClick") | UI | Button click | ✅ Yes | ✅ Yes |
handleFieldEvent(run, "customer_name", "Acme", "onChange") | UI | Input change | ✅ Yes | ✅ Yes |
handleFieldEvent(run, "submit", "submit", null) | Programmatic | API/hook call | ❌ No | ❌ No |
handleFieldEvent(run, "customer_name", "Acme", null) | Programmatic | Set field from code | ❌ No | ❌ No |
🎯 Unified Interface with Mode Variants
Option 1: Single Method with Mode Detection (Recommended)
class Controller {
// Universal entry point
handleFieldEvent(run, fieldname, value, eventType = null) {
const isUIEvent = eventType !== null;
// ... unified pipeline logic ...
// Mode-specific behavior
if (isUIEvent) {
this.debouncedSave(run);
this.updateUI(run);
} else {
this.save(run); // Direct save, no debounce
}
}
// Helper for workflow actions
isWorkflowAction(fieldname) {
return fieldname === "_action" ||
this.workflowActions.includes(fieldname);
}
}Usage:
// UI mode
<Button onClick={() =>
controller.handleFieldEvent(run, "_action", "submit", "onClick")
}>Submit</Button>
<Input onChange={(e) =>
controller.handleFieldEvent(run, "customer_name", e.target.value, "onChange")
} />
// Programmatic mode
controller.handleFieldEvent(run, "submit", "submit", null);
controller.handleFieldEvent(run, "customer_name", "Acme Corp", null);Option 2: Explicit Mode Methods (More Explicit)
class Controller {
// UI events
handleUIEvent(run, fieldname, value, eventType) {
return this.handleFieldEvent(run, fieldname, value, eventType, { isUI: true });
}
// Programmatic updates
setField(run, fieldname, value) {
return this.handleFieldEvent(run, fieldname, value, null, { isUI: false });
}
// Programmatic workflow actions
executeAction(run, actionName) {
return this.handleFieldEvent(run, "_action", actionName, null, { isUI: false });
}
// Internal universal handler
handleFieldEvent(run, fieldname, value, eventType, { isUI }) {
run.input[fieldname] = value;
// ... unified pipeline logic ...
if (isUI) {
this.debouncedSave(run);
this.updateUI(run);
} else {
this.save(run);
}
}
}Usage:
// UI mode
controller.handleUIEvent(run, "_action", "submit", "onClick");
controller.handleUIEvent(run, "customer_name", "Acme", "onChange");
// Programmatic mode
controller.setField(run, "customer_name", "Acme Corp");
controller.executeAction(run, "submit");Option 3: Builder Pattern (Most Flexible)
class Controller {
handleFieldEvent(run, fieldname, value, eventType = null) {
return new FieldEventHandler(this, run, fieldname, value, eventType);
}
}
class FieldEventHandler {
constructor(controller, run, fieldname, value, eventType) {
this.controller = controller;
this.run = run;
this.fieldname = fieldname;
this.value = value;
this.eventType = eventType;
this.isUI = eventType !== null;
}
async execute() {
// Write to input
this.run.input[this.fieldname] = this.value;
// Check permissions
if (!this.checkAllowed()) return;
// Route action
if (this.isWorkflowAction()) {
await this.triggerWorkflow();
} else {
await this.updateField();
}
// Post-processing
if (this.isUI) {
this.controller.debouncedSave(this.run);
this.controller.updateUI(this.run);
} else {
await this.controller.save(this.run);
}
}
// Helpers
isWorkflowAction() {
return this.fieldname === "_action" ||
this.controller.workflowActions.includes(this.fieldname);
}
checkAllowed() {
return this.controller.checkAllowed(
this.run,
this.fieldname,
this.eventType
);
}
}Usage:
// UI mode
await controller.handleFieldEvent(run, "_action", "submit", "onClick").execute();
// Programmatic mode
await controller.handleFieldEvent(run, "submit", "submit", null).execute();🎯 My Recommendation: Option 1 (Single Method with Mode Detection)
Why:
- ✅ Simplest API
- ✅ Mode automatically detected from
eventType === null - ✅ No duplication of pipeline logic
- ✅ Easy to understand: presence of eventType = UI mode
Implementation:
class Controller {
handleFieldEvent(run, fieldname, value, eventType = null) {
const isUIMode = eventType !== null;
// 1️⃣ Universal input handling
run.input[fieldname] = value;
// 2️⃣ Permission check
const allowed = this.checkAllowed(run, fieldname, eventType);
if (!allowed) {
delete run.input[fieldname];
if (isUIMode) this.showError(run, "Permission denied");
return false;
}
// 3️⃣ Route to appropriate handler
if (fieldname === "_action") {
// Workflow action
this.triggerWorkflowAction(value, run);
delete run.input._action;
} else {
// Field update
const field = run.target.schema.fields[fieldname];
const action = this.resolveAction(field, eventType);
this.executeAction(action, run, fieldname, value);
}
// 4️⃣ Mode-specific post-processing
if (isUIMode) {
// UI mode: debounce + re-render
if (this.shouldAutoSave(run)) {
this.debouncedSave(run);
}
this.updateUI(run);
} else {
// Programmatic mode: immediate save if needed
if (this.shouldAutoSave(run)) {
this.save(run);
}
}
return true;
}
resolveAction(field, eventType) {
if (!eventType) {
// Programmatic mode: always write_draft
return "write_draft";
}
// UI mode: use resolver
return ACTION_RESOLVER[field.fieldtype]?.[eventType] || "write_draft";
}
}
```
---
## 📊 Final Flow Comparison
### UI Event Flow:
```
User clicks "Submit" button
↓
handleFieldEvent(run, "_action", "submit", "onClick")
↓
run.input._action = "submit"
↓
Check permissions (with eventType context)
↓
Trigger workflow action
↓
Update doc.state
↓
Debounced autosave
↓
UI re-render
```
### Programmatic Flow:
```
Hook/API calls submit action
↓
handleFieldEvent(run, "submit", "submit", null)
↓
run.input.submit = "submit" // or run.input._action = "submit"
↓
Check permissions (no event context)
↓
Trigger workflow action
↓
Update doc.state
↓
Immediate save (no debounce)
↓
No UI update✅ Summary
The bi-modal pattern gives you:
- Single universal entry point:
handleFieldEvent() - Mode auto-detection:
eventType !== null→ UI mode - Unified pipeline: Same permission checks, validation, workflow logic
- Mode-specific behavior:
- UI: debouncing, re-rendering, user feedback
- Programmatic: immediate execution, no UI concerns
- Clean API: Same method signature for both modes
This is the cleanest way to support both UI-driven and programmatic field updates through a single universal pipeline.
Want me to add the complete code example with both modes integrated?
Absolutely right! It's not just field events — it's any event that flows through the controller. Let me correct the terminology:
✅ Universal Event Handler (Not Just Fields)
class Controller {
// Universal event handler - handles ANY event in the system
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
// 1️⃣ Universal input: write event to run.input
run.input[eventName] = eventData;
// 2️⃣ Permission check
const allowed = this.checkAllowed(run, eventName, eventType);
if (!allowed) {
delete run.input[eventName];
if (isUIMode) this.showError(run, "Permission denied");
return false;
}
// 3️⃣ Route event to appropriate handler
if (this.isWorkflowAction(eventName)) {
// Workflow action (buttons, programmatic actions)
this.triggerWorkflowAction(eventData, run);
delete run.input[eventName]; // Transient
} else if (this.isFieldEvent(eventName)) {
// Field update event
this.handleFieldUpdate(run, eventName, eventData, eventType);
} else if (this.isSystemEvent(eventName)) {
// System events (hooks, timers, etc.)
this.handleSystemEvent(run, eventName, eventData);
} else {
// Custom event
this.handleCustomEvent(run, eventName, eventData);
}
// 4️⃣ Mode-specific post-processing
if (isUIMode) {
// UI mode: debounce + re-render
if (this.shouldAutoSave(run)) {
this.debouncedSave(run);
}
this.updateUI(run);
} else {
// Programmatic mode: immediate execution
if (this.shouldAutoSave(run)) {
this.save(run);
}
}
return true;
}
// Event type checkers
isWorkflowAction(eventName) {
return eventName === "_action" ||
this.workflowActions.includes(eventName) ||
eventName.startsWith("action:");
}
isFieldEvent(eventName) {
return eventName in run.target.schema.fields;
}
isSystemEvent(eventName) {
return eventName.startsWith("system:") ||
["onLoad", "onSave", "onSubmit", "onCancel"].includes(eventName);
}
}📋 Event Types Table
| Event Category | Example Events | Entry Point | Mode |
|---|---|---|---|
| Field Events | customer_name, amount, date | User input, API | Both |
| Workflow Actions | _action, submit, cancel, approve | Buttons, API | Both |
| System Events | system:onLoad, system:onSave | Framework hooks | Programmatic |
| Custom Events | custom:calculateTotal, custom:sendEmail | Business logic | Programmatic |
| Timer Events | timer:autosave, timer:refresh | Scheduled tasks | Programmatic |
🎯 Universal Event Patterns
1️⃣ Field Update Events (UI & Programmatic)
// UI mode
<Input
onChange={(e) =>
controller.handleEvent(run, "customer_name", e.target.value, "onChange")
}
onBlur={(e) =>
controller.handleEvent(run, "customer_name", e.target.value, "onBlur")
}
/>
// Programmatic mode
controller.handleEvent(run, "customer_name", "Acme Corp", null);2️⃣ Workflow Action Events (UI & Programmatic)
// UI mode - button click
<Button onClick={() =>
controller.handleEvent(run, "_action", "submit", "onClick")
}>Submit</Button>
// Programmatic mode - API call
controller.handleEvent(run, "_action", "submit", null);
// Alternative: direct action name
controller.handleEvent(run, "submit", null, null);3️⃣ System Events (Programmatic Only)
// Framework hooks
controller.handleEvent(run, "system:onLoad", { timestamp: Date.now() }, null);
controller.handleEvent(run, "system:beforeSave", { user: currentUser }, null);
controller.handleEvent(run, "system:afterSubmit", { docname: run.doc.name }, null);4️⃣ Custom Business Events (Programmatic Only)
// Custom calculations
controller.handleEvent(run, "custom:calculateTotal", { tax_rate: 0.1 }, null);
// Custom integrations
controller.handleEvent(run, "custom:syncToERP", { erp_id: "ERP-001" }, null);
// Custom notifications
controller.handleEvent(run, "custom:sendApprovalEmail", {
approver: "john@example.com"
}, null);5️⃣ Timer/Scheduled Events (Programmatic Only)
// Autosave timer
setInterval(() => {
if (run.isDirty) {
controller.handleEvent(run, "timer:autosave", { interval: 30000 }, null);
}
}, 30000);
// Periodic refresh
controller.handleEvent(run, "timer:refresh", { source: "polling" }, null);🔄 Event Router Architecture
class Controller {
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
// Universal entry point
run.input[eventName] = eventData;
// Route to specific handler based on event pattern
const handler = this.getEventHandler(eventName);
if (!handler) {
console.warn(`No handler for event: ${eventName}`);
delete run.input[eventName];
return false;
}
// Execute handler
const result = handler.execute(run, eventName, eventData, eventType);
// Post-processing based on mode
this.postProcess(run, isUIMode);
return result;
}
getEventHandler(eventName) {
// Event name patterns
if (eventName === "_action") return new WorkflowActionHandler();
if (eventName.startsWith("action:")) return new WorkflowActionHandler();
if (eventName.startsWith("system:")) return new SystemEventHandler();
if (eventName.startsWith("custom:")) return new CustomEventHandler();
if (eventName.startsWith("timer:")) return new TimerEventHandler();
// Default: field event handler
return new FieldEventHandler();
}
postProcess(run, isUIMode) {
if (isUIMode) {
if (this.shouldAutoSave(run)) this.debouncedSave(run);
this.updateUI(run);
} else {
if (this.shouldAutoSave(run)) this.save(run);
}
}
}
```
---
## 📊 Complete Event Flow Diagram
```
┌─────────────────────────────────────────────┐
│ EVENT SOURCES │
├─────────────────────────────────────────────┤
│ • UI Events (onChange, onClick, onBlur) │
│ • API Calls (REST, GraphQL) │
│ • System Hooks (onLoad, onSave) │
│ • Custom Business Logic │
│ • Timers / Schedulers │
│ • External Integrations │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ controller.handleEvent() │
│ (eventName, eventData, eventType) │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Write to run.input[eventName] │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Event Router │
│ ┌──────────────────────────────────────┐ │
│ │ Pattern Matching: │ │
│ │ • "_action" → Workflow │ │
│ │ • "action:*" → Workflow │ │
│ │ • "system:*" → System Hook │ │
│ │ • "custom:*" → Custom Logic │ │
│ │ • "timer:*" → Scheduled Task │ │
│ │ • fieldname → Field Update │ │
│ └──────────────────────────────────────┘ │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Execute Event Handler │
│ • Check permissions (workflow + guardian) │
│ • Validate event data │
│ • Update document state │
│ • Trigger side effects │
└──────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Mode-Specific Post-Processing │
│ ┌──────────────────┬──────────────────┐ │
│ │ UI Mode │ Programmatic │ │
│ │ (eventType ≠ │ (eventType = │ │
│ │ null) │ null) │ │
│ ├──────────────────┼──────────────────┤ │
│ │ • Debounced save │ • Immediate save │ │
│ │ • UI re-render │ • No UI update │ │
│ │ • User feedback │ • Return result │ │
│ └──────────────────┴──────────────────┘ │
└─────────────────────────────────────────────┘✅ Renamed API Summary
// ❌ Old (field-specific)
controller.handleFieldEvent(run, fieldname, value, eventType)
// ✅ New (universal event handler)
controller.handleEvent(run, eventName, eventData, eventType)Usage examples:
// Field events
controller.handleEvent(run, "customer_name", "Acme", "onChange");
// Workflow actions
controller.handleEvent(run, "_action", "submit", "onClick");
// System hooks
controller.handleEvent(run, "system:onLoad", {}, null);
// Custom events
controller.handleEvent(run, "custom:calculateTotal", { tax: 0.1 }, null);
// Timer events
controller.handleEvent(run, "timer:autosave", {}, null);✅ Final Architecture Benefits
- Truly Universal: Handles any event type, not just fields
- Bi-Modal: Supports both UI and programmatic modes
- Single Entry Point: All events flow through
handleEvent() - Pattern-Based Routing: Event name determines handler
- Mode Detection:
eventType === null→ programmatic mode - Extensible: Easy to add new event types and handlers
- Consistent: Same pipeline for all events
This is now a complete universal event system, not just a field update handler!
Want me to add event handler implementations for the different event types?
Perfect! Now let's refactor MainForm to send all events through the universal controller.handleEvent() without hardcoding actions. The key insight is: MainForm should only emit events, not execute actions.
✅ Refactored MainForm (Event Emitter Pattern)
const MainForm = ({ run }) => {
const [schema, setSchema] = React.useState(run?.target?.schema || null);
const doc = run?.doc || {};
const doctype = doc.doctype || run?.source_doctype || run?.target_doctype;
// Load schema if missing
React.useEffect(() => {
if (!schema && doctype && coworker?.getSchema) {
coworker.getSchema(doctype).then(setSchema);
}
}, [doctype]);
// Guard clause
if (!schema) {
return React.createElement(
"div",
{ className: CWStyles.alert.warning },
"Loading schema..."
);
}
// Safe extracts
const titleField = schema.title_field || "name";
const title = doc[titleField] || doc.name || "New";
const fields = schema.fields || [];
// Whitelist
const implementedTypes = [
"Data", "Text", "Long Text", "Password", "Read Only",
"Int", "Float", "Currency", "Percent",
"Check",
"Date", "Datetime", "Time",
"Select", "Link",
"Text Editor", "Code", "HTML",
"Section Break", "Column Break", "Tab Break",
"Button", "Attach Image",
];
// Get interaction profile
const interactionConfig = coworker._config.fieldInteractionConfig;
const profile = interactionConfig.profiles[interactionConfig.activeProfile];
// Debounce timers
const timersRef = React.useRef({});
// ✅ Universal event emitter - NO ACTION LOGIC HERE
const emitEvent = React.useCallback((eventName, eventData, eventType) => {
// Get debounce config for this eventType
const eventConfig = profile[eventType];
if (!eventConfig?.enabled) {
console.log(`⚠️ Event ${eventType} disabled by profile`);
return;
}
const perform = () => {
// ✅ Simply forward to controller - let it decide what to do
coworker.controller.handleEvent(run, eventName, eventData, eventType);
};
// Handle debouncing if configured
if (eventConfig.debounce > 0) {
const timerKey = `${eventType}_${eventName}`;
clearTimeout(timersRef.current[timerKey]);
timersRef.current[timerKey] = setTimeout(perform, eventConfig.debounce);
} else {
perform();
}
}, [run, profile]);
// ✅ Event handlers - just thin wrappers around emitEvent
const handlers = React.useMemo(() => ({
onChange: (fieldname, value) => {
emitEvent(fieldname, value, "onChange");
},
onBlur: (fieldname, value) => {
emitEvent(fieldname, value, "onBlur");
},
onClick: (eventName, eventData = null) => {
emitEvent(eventName, eventData, "onClick");
},
onFocus: (fieldname, value) => {
emitEvent(fieldname, value, "onFocus");
},
// Add more event types as needed
onKeyDown: (fieldname, event) => {
emitEvent(fieldname, event, "onKeyDown");
},
}), [emitEvent]);
// Cleanup timers on unmount
React.useEffect(() => {
return () => {
Object.values(timersRef.current).forEach(clearTimeout);
};
}, []);
// ... rest of render logic
return React.createElement(
"div",
{ className: CWStyles.form.container },
// Header
React.createElement(
"div",
{ className: CWStyles.form.header },
React.createElement("h2", null, title)
),
// Fields
fields
.filter(f => implementedTypes.includes(f.fieldtype))
.map(field =>
React.createElement(FieldComponent, {
key: field.fieldname,
field,
doc,
onChange: (value) => handlers.onChange(field.fieldname, value),
onBlur: (value) => handlers.onBlur(field.fieldname, value),
onClick: () => handlers.onClick(field.fieldname),
})
)
);
};🎯 Key Changes
❌ Before (Hardcoded Actions)
const executeAction = (action, fieldname, value) => {
switch (action) {
case "write_draft":
run.input[fieldname] = value;
break;
case "validate":
run.input[fieldname] = value;
coworker.controller.validate(run);
break;
case "auto_save":
run.input[fieldname] = value;
coworker.controller.autoSave(run);
break;
}
};
handlers.onChange = (fieldname, value) => {
executeAction(config.action, fieldname, value);
};✅ After (Pure Event Emission)
const emitEvent = (eventName, eventData, eventType) => {
// Just forward to controller - let it decide the action
coworker.controller.handleEvent(run, eventName, eventData, eventType);
};
handlers.onChange = (fieldname, value) => {
emitEvent(fieldname, value, "onChange");
};📊 Responsibility Split
| Component | Responsibility | What It Does | What It Doesn't Do |
|---|---|---|---|
| MainForm | Event emission | • Capture UI events • Apply debouncing • Check if event enabled • Forward to controller | ❌ Execute actions ❌ Update run.input ❌ Validate ❌ Auto-save |
| Controller | Event handling | • Route events • Execute actions • Update state • Trigger side effects | ❌ Know about UI ❌ Handle debouncing |
| Profile Config | Event rules | • Enable/disable events • Set debounce timing | ❌ Define actions |
🔧 Controller Side (What Receives Events)
class Controller {
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
console.log(`📥 Received event: ${eventType || 'programmatic'}`, {
eventName,
eventData,
isUIMode
});
// 1️⃣ Write to universal input
run.input[eventName] = eventData;
// 2️⃣ Get behavior config
const behavior = this.getBehavior(run.target.schema, run.doc);
// 3️⃣ Check permissions
if (!this.checkAllowed(run, eventName, eventType, behavior)) {
delete run.input[eventName];
if (isUIMode) this.showError(run, "Action not allowed");
return false;
}
// 4️⃣ Route to handler
if (eventName === "_action" || this.isWorkflowAction(eventName)) {
// Workflow action
this.triggerWorkflowAction(eventData, run);
delete run.input[eventName];
} else {
// Field event - resolve action from config
const action = this.resolveAction(run, eventName, eventType, behavior);
this.executeAction(action, run, eventName, eventData);
}
// 5️⃣ Post-processing
if (isUIMode) {
if (behavior.controller.autoSave && this.shouldAutoSave(run)) {
this.debouncedSave(run);
}
this.updateUI(run);
} else {
if (behavior.controller.autoSave && this.shouldAutoSave(run)) {
this.save(run);
}
}
return true;
}
// Resolve which action to execute based on event + config
resolveAction(run, eventName, eventType, behavior) {
// Get field config
const field = run.target.schema.fields.find(f => f.fieldname === eventName);
if (!field) return "write_draft";
// Get interaction profile
const profile = this.getActiveProfile();
const eventConfig = profile[eventType];
if (!eventConfig?.enabled) return null;
// Return configured action
return eventConfig.action;
}
executeAction(action, run, fieldname, value) {
switch (action) {
case "write_draft":
run.input[fieldname] = value;
console.log(`✅ Draft: ${fieldname} = ${value}`);
break;
case "validate":
run.input[fieldname] = value;
if (this.validate) {
const validation = this.validate(run);
if (validation && !validation.valid) {
run._validationErrors = validation.errors;
}
}
console.log(`✅ Validated: ${fieldname}`);
break;
case "auto_save":
// This is now handled in post-processing
run.input[fieldname] = value;
console.log(`✅ Marked for auto-save: ${fieldname}`);
break;
default:
console.warn(`Unknown action: ${action}`);
}
}
}
```
---
## 📋 Complete Event Flow
```
┌─────────────────────────┐
│ MainForm (UI) │
│─────────────────────────│
│ User types in field │
│ ↓ │
│ onChange handler │
│ ↓ │
│ handlers.onChange( │
│ "customer_name", │
│ "Acme Corp" │
│ ) │
└────────┬────────────────┘
│
▼
┌─────────────────────────┐
│ emitEvent() │
│─────────────────────────│
│ 1. Check profile config │
│ 2. Apply debouncing │
│ 3. Forward to controller│
└────────┬────────────────┘
│
▼
┌─────────────────────────┐
│ controller.handleEvent( │
│ run, │
│ "customer_name", │
│ "Acme Corp", │
│ "onChange" │
│ ) │
└────────┬────────────────┘
│
▼
┌─────────────────────────┐
│ Controller │
│─────────────────────────│
│ 1. Write to run.input │
│ 2. Check permissions │
│ 3. Resolve action │
│ 4. Execute action │
│ 5. Auto-save if needed │
│ 6. Update UI │
└─────────────────────────┘✅ Benefits of This Approach
| Benefit | Description |
|---|---|
| Zero Hardcoding | MainForm has no knowledge of actions (write_draft, validate, auto_save) |
| Pure Event Emission | MainForm only emits events with data |
| Controller Decides | Controller resolves action based on config + behavior + workflow |
| Easy to Test | Can test MainForm by mocking controller.handleEvent() |
| Profile Switching | Changing profile immediately changes behavior without MainForm changes |
| Extensible | Add new event types without touching MainForm logic |
🎯 Example: Adding a New Event Type
1️⃣ Add to Profile Config
fieldInteractionConfig: {
profiles: {
standard: {
onChange: { enabled: true, action: "write_draft", debounce: 300 },
onBlur: { enabled: true, action: "validate", debounce: 0 },
onFocus: { enabled: true, action: "log_focus", debounce: 0 }, // NEW
}
}
}2️⃣ Add Handler to MainForm (One Line)
const handlers = React.useMemo(() => ({
onChange: (fieldname, value) => emitEvent(fieldname, value, "onChange"),
onBlur: (fieldname, value) => emitEvent(fieldname, value, "onBlur"),
onFocus: (fieldname, value) => emitEvent(fieldname, value, "onFocus"), // NEW
}), [emitEvent]);3️⃣ Add Action Handler to Controller
executeAction(action, run, fieldname, value) {
// ... existing cases ...
case "log_focus":
console.log(`👁️ Field focused: ${fieldname}`);
run._lastFocused = { fieldname, timestamp: Date.now() };
break;
}That's it! No changes to event emission logic, no hardcoded switches in MainForm.
✅ Final Refactored MainForm Summary
What MainForm Does:
- ✅ Captures UI events (onChange, onBlur, onClick, etc.)
- ✅ Checks if event is enabled in profile
- ✅ Applies debouncing from profile config
- ✅ Forwards events to
controller.handleEvent()
What MainForm Doesn't Do:
- ❌ Decide which action to execute
- ❌ Update
run.inputdirectly - ❌ Validate fields
- ❌ Trigger auto-save
- ❌ Know about workflow states
This is now a pure event emitter with zero business logic! Want me to show the complete integrated example with buttons and workflow actions included?
Perfect! Now let's tackle the harder part: Button generation and onClick routing through the universal controller, assuming a default workflow exists for the doctype.
🎯 The Challenge
- Buttons must be generated from workflow state (not hardcoded in MainForm)
- Button onClick must route through
controller.handleEvent()(not direct action calls) - Controller must resolve button → workflow action (using workflow config)
- Default workflow must be inferred from
schema.is_submittable
1️⃣ Default Workflow Definition
// In coworker config or schema processor
const DEFAULT_WORKFLOWS = {
// For is_submittable = 1 doctypes
submittable: {
name: "Default Submittable Workflow",
states: [
{ name: "Draft", value: 0 },
{ name: "Submitted", value: 1 },
{ name: "Cancelled", value: 2 },
],
transitions: [
{
from: "Draft",
to: "Submitted",
action: "submit",
label: "Submit",
condition: (run) => !run.doc.docstatus || run.doc.docstatus === 0,
},
{
from: "Draft",
to: "Draft",
action: "save",
label: "Save",
condition: (run) => !run.doc.docstatus || run.doc.docstatus === 0,
},
{
from: "Submitted",
to: "Cancelled",
action: "cancel",
label: "Cancel",
condition: (run) => run.doc.docstatus === 1,
},
{
from: "Cancelled",
to: "Draft",
action: "amend",
label: "Amend",
condition: (run) => run.doc.docstatus === 2,
},
],
},
// For is_submittable = 0 doctypes
simple: {
name: "Simple Workflow",
states: [
{ name: "Draft", value: 0 },
],
transitions: [
{
from: "Draft",
to: "Draft",
action: "save",
label: "Save",
condition: (run) => true,
},
],
},
};
// Helper to get workflow for a doctype
coworker.getWorkflow = function(schema, doc) {
// Check for custom workflow first
if (schema.workflow) {
return schema.workflow;
}
// Use default workflow based on is_submittable
if (schema.is_submittable) {
return DEFAULT_WORKFLOWS.submittable;
}
return DEFAULT_WORKFLOWS.simple;
};
// Helper to get current state
coworker.getCurrentState = function(workflow, doc) {
const docstatus = doc.docstatus || 0;
return workflow.states.find(s => s.value === docstatus) || workflow.states[0];
};
// Helper to get available actions for current state
coworker.getAvailableActions = function(workflow, run) {
const currentState = this.getCurrentState(workflow, run.doc);
return workflow.transitions
.filter(t => t.from === currentState.name)
.filter(t => !t.condition || t.condition(run))
.map(t => ({
action: t.action,
label: t.label,
to: t.to,
}));
};2️⃣ Button Generator Component
const WorkflowButtons = ({ run, onAction }) => {
const schema = run?.target?.schema;
const doc = run?.doc || {};
if (!schema) return null;
// Get workflow (custom or default)
const workflow = coworker.getWorkflow(schema, doc);
// Get available actions for current state
const availableActions = coworker.getAvailableActions(workflow, run);
if (availableActions.length === 0) {
return null;
}
return React.createElement(
"div",
{ className: CWStyles.form.buttonBar },
availableActions.map(({ action, label }) =>
React.createElement(
"button",
{
key: action,
className: CWStyles.button[action] || CWStyles.button.primary,
onClick: () => onAction(action),
},
label
)
)
);
};3️⃣ Updated MainForm with Button Integration
const MainForm = ({ run }) => {
const [schema, setSchema] = React.useState(run?.target?.schema || null);
const [forceUpdate, setForceUpdate] = React.useState(0);
const doc = run?.doc || {};
const doctype = doc.doctype || run?.source_doctype || run?.target_doctype;
// Load schema if missing
React.useEffect(() => {
if (!schema && doctype && coworker?.getSchema) {
coworker.getSchema(doctype).then(setSchema);
}
}, [doctype]);
// Guard clause
if (!schema) {
return React.createElement(
"div",
{ className: CWStyles.alert.warning },
"Loading schema..."
);
}
// Safe extracts
const titleField = schema.title_field || "name";
const title = doc[titleField] || doc.name || "New";
const fields = schema.fields || [];
// Get interaction profile
const interactionConfig = coworker._config.fieldInteractionConfig;
const profile = interactionConfig.profiles[interactionConfig.activeProfile];
// Debounce timers
const timersRef = React.useRef({});
// ✅ Universal event emitter
const emitEvent = React.useCallback((eventName, eventData, eventType) => {
const eventConfig = profile[eventType];
if (!eventConfig?.enabled && eventType !== "onClick") {
// onClick always allowed for buttons
console.log(`⚠️ Event ${eventType} disabled by profile`);
return;
}
const perform = () => {
// Forward to controller
const success = coworker.controller.handleEvent(
run,
eventName,
eventData,
eventType
);
// Force re-render on successful workflow action
if (success && eventType === "onClick") {
setForceUpdate(prev => prev + 1);
}
};
// Handle debouncing if configured
if (eventConfig?.debounce > 0) {
const timerKey = `${eventType}_${eventName}`;
clearTimeout(timersRef.current[timerKey]);
timersRef.current[timerKey] = setTimeout(perform, eventConfig.debounce);
} else {
perform();
}
}, [run, profile, forceUpdate]);
// ✅ Event handlers
const handlers = React.useMemo(() => ({
onChange: (fieldname, value) => {
emitEvent(fieldname, value, "onChange");
},
onBlur: (fieldname, value) => {
emitEvent(fieldname, value, "onBlur");
},
// ✅ Workflow action handler (for buttons)
onAction: (actionName) => {
emitEvent("_action", actionName, "onClick");
},
}), [emitEvent]);
// Cleanup timers on unmount
React.useEffect(() => {
return () => {
Object.values(timersRef.current).forEach(clearTimeout);
};
}, []);
// Get workflow state for badge display
const workflow = coworker.getWorkflow(schema, doc);
const currentState = coworker.getCurrentState(workflow, doc);
return React.createElement(
"div",
{ className: CWStyles.form.container },
// Header with status badge
React.createElement(
"div",
{ className: CWStyles.form.header },
React.createElement("h2", null, title),
React.createElement(
"span",
{
className: CWStyles.badge[currentState.name.toLowerCase()] || CWStyles.badge.default
},
currentState.name
)
),
// Workflow buttons
React.createElement(WorkflowButtons, {
run,
onAction: handlers.onAction,
}),
// Fields
React.createElement(
"div",
{ className: CWStyles.form.fields },
fields
.filter(f => implementedTypes.includes(f.fieldtype))
.map(field =>
React.createElement(FieldComponent, {
key: field.fieldname,
field,
doc,
onChange: (value) => handlers.onChange(field.fieldname, value),
onBlur: (value) => handlers.onBlur(field.fieldname, value),
readOnly: currentState.value !== 0, // Read-only if submitted/cancelled
})
)
)
);
};4️⃣ Controller: Handle Workflow Actions
class Controller {
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
console.log(`📥 Event received:`, {
eventName,
eventData,
eventType,
isUIMode
});
// 1️⃣ Write to universal input
run.input[eventName] = eventData;
// 2️⃣ Get workflow
const schema = run.target.schema;
const workflow = coworker.getWorkflow(schema, run.doc);
const currentState = coworker.getCurrentState(workflow, run.doc);
// 3️⃣ Check if this is a workflow action
if (eventName === "_action") {
const actionName = eventData;
const success = this.triggerWorkflowAction(
actionName,
run,
workflow,
currentState
);
delete run.input._action; // Transient
if (isUIMode && success) {
this.updateUI(run);
}
return success;
}
// 4️⃣ Regular field event
const behavior = this.getBehavior(schema, run.doc);
// Check permissions
if (!this.checkAllowed(run, eventName, eventType, behavior, currentState)) {
delete run.input[eventName];
if (isUIMode) this.showError(run, "Action not allowed in current state");
return false;
}
// Resolve and execute action
const action = this.resolveAction(run, eventName, eventType, behavior);
if (action) {
this.executeAction(action, run, eventName, eventData);
}
// Post-processing
if (isUIMode) {
if (behavior.controller.autoSave && this.shouldAutoSave(run)) {
this.debouncedSave(run);
}
this.updateUI(run);
}
return true;
}
// ✅ Workflow action handler
triggerWorkflowAction(actionName, run, workflow, currentState) {
console.log(`🔄 Triggering workflow action: ${actionName}`);
// Find valid transition
const transition = workflow.transitions.find(
t => t.from === currentState.name &&
t.action === actionName &&
(!t.condition || t.condition(run))
);
if (!transition) {
console.warn(`❌ Transition '${actionName}' not allowed from state '${currentState.name}'`);
return false;
}
console.log(`✅ Transition: ${transition.from} → ${transition.to}`);
// Execute action-specific logic
switch (actionName) {
case "save":
return this.executeSave(run);
case "submit":
return this.executeSubmit(run, transition);
case "cancel":
return this.executeCancel(run, transition);
case "amend":
return this.executeAmend(run, transition);
default:
console.warn(`Unknown workflow action: ${actionName}`);
return false;
}
}
// ✅ Action: Save
executeSave(run) {
console.log(`💾 Executing save...`);
// Validate before save
const validation = this.validate(run);
if (validation && !validation.valid) {
run._validationErrors = validation.errors;
console.warn(`❌ Validation failed:`, validation.errors);
return false;
}
// Merge input into doc
Object.assign(run.doc, run.input);
run.input = {};
// Call save hook
if (this.save) {
this.save(run);
}
console.log(`✅ Saved successfully`);
return true;
}
// ✅ Action: Submit
executeSubmit(run, transition) {
console.log(`📤 Executing submit...`);
// Validate before submit
const validation = this.validate(run);
if (validation && !validation.valid) {
run._validationErrors = validation.errors;
console.warn(`❌ Submit validation failed:`, validation.errors);
return false;
}
// Merge input into doc
Object.assign(run.doc, run.input);
run.input = {};
// Update docstatus
const toState = transition.to;
const targetState = run.target.schema.workflow
? run.target.schema.workflow.states.find(s => s.name === toState)
: { value: 1 };
run.doc.docstatus = targetState.value;
// Call submit hook
if (this.onSubmit) {
this.onSubmit(run);
}
// Save
if (this.save) {
this.save(run);
}
console.log(`✅ Submitted successfully (docstatus = ${run.doc.docstatus})`);
return true;
}
// ✅ Action: Cancel
executeCancel(run, transition) {
console.log(`🚫 Executing cancel...`);
// Update docstatus
const toState = transition.to;
const targetState = run.target.schema.workflow
? run.target.schema.workflow.states.find(s => s.name === toState)
: { value: 2 };
run.doc.docstatus = targetState.value;
// CallYou're absolutely right. I apologized and broke the clean architecture by scattering logic across multiple places. Let me consolidate everything properly.
🎯 Clean Architecture: Single Responsibility
The problem: I scattered workflow logic, action execution, and state management across MainForm, Controller, and helper functions.
The solution: Clear separation of concerns with each component having ONE job.
✅ Proper Architecture
┌─────────────────────────────────────────────────────────┐
│ MainForm │
│ Job: Emit events only │
│ - Render UI │
│ - Capture user interactions │
│ - Call controller.handleEvent() │
│ - NO business logic │
└────────────────────┬────────────────────────────────────┘
│
│ controller.handleEvent(run, eventName, eventData, eventType)
│
▼
┌─────────────────────────────────────────────────────────┐
│ Controller │
│ Job: Universal event router & orchestrator │
│ - Receive ALL events │
│ - Route to appropriate handler │
│ - Orchestrate workflow, validation, save │
│ - Update run state │
│ - Trigger UI updates │
└────────────────────┬────────────────────────────────────┘
│
│ Uses ↓
│
┌─────────────────────────────────────────────────────────┐
│ Workflow Engine (in Controller) │
│ Job: Manage state transitions │
│ - Get current state from doc.docstatus │
│ - Get available actions │
│ - Validate transitions │
│ - Execute state changes │
└─────────────────────────────────────────────────────────┘1️⃣ MainForm: Pure Event Emitter
const MainForm = ({ run }) => {
const [schema, setSchema] = React.useState(run?.target?.schema || null);
const doc = run?.doc || {};
const doctype = doc.doctype || run?.source_doctype || run?.target_doctype;
// Load schema
React.useEffect(() => {
if (!schema && doctype && coworker?.getSchema) {
coworker.getSchema(doctype).then(setSchema);
}
}, [doctype]);
if (!schema) {
return React.createElement("div", { className: CWStyles.alert.warning }, "Loading schema...");
}
// Get config
const interactionConfig = coworker._config.fieldInteractionConfig;
const profile = interactionConfig.profiles[interactionConfig.activeProfile];
const timersRef = React.useRef({});
// ✅ ONLY JOB: Emit events to controller
const emitEvent = React.useCallback((eventName, eventData, eventType) => {
const eventConfig = profile[eventType];
// Check if event is enabled
if (eventConfig && !eventConfig.enabled) return;
const perform = () => {
// Just forward - controller decides everything
coworker.controller.handleEvent(run, eventName, eventData, eventType);
};
// Apply debouncing from config
if (eventConfig?.debounce > 0) {
const timerKey = `${eventType}_${eventName}`;
clearTimeout(timersRef.current[timerKey]);
timersRef.current[timerKey] = setTimeout(perform, eventConfig.debounce);
} else {
perform();
}
}, [run, profile]);
// Simple handlers - just emit
const onChange = (fieldname, value) => emitEvent(fieldname, value, "onChange");
const onBlur = (fieldname, value) => emitEvent(fieldname, value, "onBlur");
const onClick = (actionName) => emitEvent("_action", actionName, "onClick");
// Cleanup
React.useEffect(() => {
return () => Object.values(timersRef.current).forEach(clearTimeout);
}, []);
// ✅ Render: fields + buttons (generated by controller data)
const titleField = schema.title_field || "name";
const title = doc[titleField] || doc.name || "New";
const fields = schema.fields || [];
// Ask controller for UI state (what buttons to show, what's editable)
const uiState = coworker.controller.getUIState(run);
return React.createElement(
"div",
{ className: CWStyles.form.container },
// Header
React.createElement(
"div",
{ className: CWStyles.form.header },
React.createElement("h2", null, title),
React.createElement("span", { className: CWStyles.badge[uiState.stateName.toLowerCase()] }, uiState.stateName)
),
// Buttons (from controller)
uiState.buttons.length > 0 && React.createElement(
"div",
{ className: CWStyles.form.buttonBar },
uiState.buttons.map(btn =>
React.createElement(
"button",
{
key: btn.action,
className: CWStyles.button[btn.action] || CWStyles.button.primary,
onClick: () => onClick(btn.action),
disabled: btn.disabled,
},
btn.label
)
)
),
// Fields
React.createElement(
"div",
{ className: CWStyles.form.fields },
fields.map(field =>
React.createElement(FieldComponent, {
key: field.fieldname,
field,
doc,
onChange: (value) => onChange(field.fieldname, value),
onBlur: (value) => onBlur(field.fieldname, value),
readOnly: !uiState.fieldsEditable || field.read_only,
})
)
)
);
};2️⃣ Controller: All Logic in One Place
class Controller {
constructor(config) {
this.config = config;
this.workflows = this._initWorkflows();
}
// ═══════════════════════════════════════════════════════
// WORKFLOW SETUP
// ═══════════════════════════════════════════════════════
_initWorkflows() {
return {
submittable: {
states: [
{ name: "Draft", docstatus: 0 },
{ name: "Submitted", docstatus: 1 },
{ name: "Cancelled", docstatus: 2 },
],
transitions: [
{ from: 0, to: 0, action: "save", label: "Save", execute: (r) => this._save(r) },
{ from: 0, to: 1, action: "submit", label: "Submit", execute: (r) => this._submit(r) },
{ from: 1, to: 2, action: "cancel", label: "Cancel", execute: (r) => this._cancel(r) },
{ from: 2, to: 0, action: "amend", label: "Amend", execute: (r) => this._amend(r) },
],
},
simple: {
states: [{ name: "Draft", docstatus: 0 }],
transitions: [
{ from: 0, to: 0, action: "save", label: "Save", execute: (r) => this._save(r) },
],
},
};
}
_getWorkflow(schema) {
// Custom workflow or default
if (schema.workflow) return schema.workflow;
return schema.is_submittable ? this.workflows.submittable : this.workflows.simple;
}
_getCurrentState(workflow, doc) {
const docstatus = doc.docstatus || 0;
return workflow.states.find(s => s.docstatus === docstatus) || workflow.states[0];
}
_getAvailableTransitions(workflow, doc) {
const docstatus = doc.docstatus || 0;
return workflow.transitions.filter(t => t.from === docstatus);
}
// ═══════════════════════════════════════════════════════
// UNIVERSAL EVENT HANDLER (entry point)
// ═══════════════════════════════════════════════════════
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
console.log(`📥 ${eventType || 'programmatic'}:`, eventName, eventData);
// Write to input
run.input[eventName] = eventData;
// Route event
if (eventName === "_action") {
// Workflow action
const success = this._handleWorkflowAction(run, eventData);
delete run.input._action;
if (isUIMode) this._triggerUIUpdate(run);
return success;
} else {
// Field event
return this._handleFieldEvent(run, eventName, eventData, eventType, isUIMode);
}
}
// ═══════════════════════════════════════════════════════
// WORKFLOW ACTION HANDLER
// ═══════════════════════════════════════════════════════
_handleWorkflowAction(run, actionName) {
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const currentDocstatus = run.doc.docstatus || 0;
// Find transition
const transition = workflow.transitions.find(
t => t.from === currentDocstatus && t.action === actionName
);
if (!transition) {
console.warn(`❌ Action '${actionName}' not allowed from docstatus ${currentDocstatus}`);
return false;
}
console.log(`🔄 ${actionName}: docstatus ${transition.from} → ${transition.to}`);
// Execute transition (contains all logic)
return transition.execute(run);
}
// ═══════════════════════════════════════════════════════
// FIELD EVENT HANDLER
// ═══════════════════════════════════════════════════════
_handleFieldEvent(run, fieldname, value, eventType, isUIMode) {
// Get action from profile config
const profile = this._getActiveProfile();
const eventConfig = profile[eventType];
if (!eventConfig || !eventConfig.enabled) {
delete run.input[fieldname];
return false;
}
const action = eventConfig.action;
// Execute action
switch (action) {
case "write_draft":
// Just keep in run.input (already written)
console.log(`✏️ Draft: ${fieldname} = ${value}`);
break;
case "validate":
this._validateField(run, fieldname, value);
break;
case "auto_save":
// Will be handled in post-processing
break;
default:
console.warn(`Unknown action: ${action}`);
}
// Post-processing
if (isUIMode && this._shouldAutoSave(run)) {
this._debouncedSave(run);
}
if (isUIMode) this._triggerUIUpdate(run);
return true;
}
// ═══════════════════════════════════════════════════════
// WORKFLOW ACTION IMPLEMENTATIONS
// ═══════════════════════════════════════════════════════
_save(run) {
console.log(`💾 Saving...`);
// Validate
if (!this._validate(run)) return false;
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// Persist (mock for now)
console.log(`✅ Saved:`, run.doc);
return true;
}
_submit(run) {
console.log(`📤 Submitting...`);
// Validate
if (!this._validate(run)) return false;
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// Update docstatus
run.doc.docstatus = 1;
// Persist
console.log(`✅ Submitted:`, run.doc);
return true;
}
_cancel(run) {
console.log(`🚫 Cancelling...`);
run.doc.docstatus = 2;
console.log(`✅ Cancelled:`, run.doc);
return true;
}
_amend(run) {
console.log(`🔄 Amending...`);
// Create new draft from cancelled doc
const amended = { ...run.doc };
amended.docstatus = 0;
amended.amended_from = run.doc.name;
delete amended.name;
run.doc = amended;
console.log(`✅ Amended:`, run.doc);
return true;
}
// ═══════════════════════════════════════════════════════
// VALIDATION
// ═══════════════════════════════════════════════════════
_validate(run) {
// Mock validation
run._validationErrors = {};
const schema = run.target.schema;
const doc = { ...run.doc, ...run.input };
for (let field of schema.fields) {
if (field.reqd && !doc[field.fieldname]) {
run._validationErrors[field.fieldname] = `${field.label} is required`;
}
}
const isValid = Object.keys(run._validationErrors).length === 0;
if (!isValid) {
console.warn(`❌ Validation failed:`, run._validationErrors);
}
return isValid;
}
_validateField(run, fieldname, value) {
// Single field validation
console.log(`✓ Validated: ${fieldname}`);
}
// ═══════════════════════════════════════════════════════
// UI STATE (what MainForm needs to render)
// ═══════════════════════════════════════════════════════
getUIState(run) {
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const currentState = this._getCurrentState(workflow, run.doc);
const availableTransitions = this._getAvailableTransitions(workflow, run.doc);
return {
stateName: currentState.name,
fieldsEditable: currentState.docstatus === 0, // Only draft is editable
buttons: availableTransitions.map(t => ({
action: t.action,
label: t.label,
disabled: false,
})),
};
}
// ═══════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════
_getActiveProfile() {
const config = this.config.fieldInteractionConfig;
return config.profiles[config.activeProfile];
}
_shouldAutoSave(run) {
// Check if there's unsaved input
return Object.keys(run.input).length > 0;
}
_debouncedSave(run) {
// Mock debounced save
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this._save(run), 2000);
}
_triggerUIUpdate(run) {
// Mock UI update trigger (in real app, would trigger React re-render)
console.log(`🔄 UI update triggered`);
}
}✅ Summary: Everything in Right Place
| Component | Responsibility | What It Does | What It Doesn't Do |
|---|---|---|---|
| MainForm | UI & Event Emission | • Render form • Capture events • Call controller.handleEvent()• Get UI state from controller | ❌ Business logic ❌ Workflow logic ❌ Validation ❌ Save logic |
| Controller | All Logic | • Route events • Manage workflow • Execute actions • Validate • Save • Provide UI state | ❌ Render UI ❌ Know about React |
| Workflow | State Machine | • Embedded in controller • Define states & transitions • Execute action logic | ❌ Scattered across files |
Result: One file (Controller), all logic, clean and maintainable. MainForm is dumb, just emits events.
Is this the clean architecture you want?
Brilliant insight! You're absolutely right. If we write everything to run.doc.state in a consistent shape, we can have a single universal _handleEvent() pipeline with no special cases.
🎯 The Key Insight
Instead of:
_handleWorkflowAction()for buttons_handleFieldEvent()for inputs- Different code paths for different event types
We should:
- Everything writes to
run.doc.statein the same shape - Single
_handleEvent()pipeline that processes the state - Case-based routing for different event types, but same structure
✅ Unified Event State Structure
// All events write to run.doc.state with consistent shape
run.doc.state = {
// Current workflow state
current: "Draft", // or use docstatus: 0
docstatus: 0,
// Pending events (queue)
events: [
{
type: "field_change",
fieldname: "customer_name",
value: "Acme Corp",
eventType: "onChange",
timestamp: Date.now(),
},
{
type: "workflow_action",
action: "submit",
eventType: "onClick",
timestamp: Date.now(),
},
],
// Validation state
validation: {
errors: {},
warnings: {},
},
// Flags
_saving: false,
_dirty: false,
};✅ Universal Controller: Single Pipeline
class Controller {
constructor(config) {
this.config = config;
this.workflows = this._initWorkflows();
}
// ═══════════════════════════════════════════════════════
// WORKFLOW DEFINITIONS (same as before)
// ═══════════════════════════════════════════════════════
_initWorkflows() {
return {
submittable: {
states: [
{ name: "Draft", docstatus: 0 },
{ name: "Submitted", docstatus: 1 },
{ name: "Cancelled", docstatus: 2 },
],
actions: {
save: { from: [0], to: 0, label: "Save" },
submit: { from: [0], to: 1, label: "Submit" },
cancel: { from: [1], to: 2, label: "Cancel" },
amend: { from: [2], to: 0, label: "Amend" },
},
},
simple: {
states: [{ name: "Draft", docstatus: 0 }],
actions: {
save: { from: [0], to: 0, label: "Save" },
},
},
};
}
// ═══════════════════════════════════════════════════════
// UNIVERSAL EVENT HANDLER (single entry point)
// ═══════════════════════════════════════════════════════
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
console.log(`📥 Event:`, { eventName, eventData, eventType });
// Initialize state if missing
if (!run.doc.state) {
run.doc.state = {
docstatus: run.doc.docstatus || 0,
events: [],
validation: { errors: {}, warnings: {} },
_saving: false,
_dirty: false,
};
}
// ✅ Write event to state in consistent shape
const event = this._normalizeEvent(eventName, eventData, eventType);
run.doc.state.events.push(event);
// ✅ Process event through single pipeline
const result = this._processEvent(run, event, isUIMode);
// ✅ Post-processing
if (isUIMode) {
this._postProcess(run);
}
return result;
}
// ═══════════════════════════════════════════════════════
// EVENT NORMALIZATION
// ═══════════════════════════════════════════════════════
_normalizeEvent(eventName, eventData, eventType) {
// Detect event type from name/pattern
let type;
if (eventName === "_action") {
type = "workflow_action";
} else if (eventName.startsWith("action:")) {
type = "workflow_action";
eventData = eventName.replace("action:", "");
} else if (eventName.startsWith("system:")) {
type = "system_event";
} else if (eventName.startsWith("custom:")) {
type = "custom_event";
} else {
type = "field_change";
}
return {
type,
eventName,
eventData,
eventType,
timestamp: Date.now(),
};
}
// ═══════════════════════════════════════════════════════
// SINGLE EVENT PROCESSOR (case pipeline)
// ═══════════════════════════════════════════════════════
_processEvent(run, event, isUIMode) {
console.log(`⚙️ Processing:`, event.type, event.eventData);
// Route by event type
switch (event.type) {
case "field_change":
return this._processFieldChange(run, event, isUIMode);
case "workflow_action":
return this._processWorkflowAction(run, event);
case "system_event":
return this._processSystemEvent(run, event);
case "custom_event":
return this._processCustomEvent(run, event);
default:
console.warn(`Unknown event type: ${event.type}`);
return false;
}
}
// ═══════════════════════════════════════════════════════
// EVENT TYPE PROCESSORS (uniform interface)
// ═══════════════════════════════════════════════════════
_processFieldChange(run, event, isUIMode) {
const { eventName: fieldname, eventData: value, eventType } = event;
// Get action from profile
const profile = this._getActiveProfile();
const eventConfig = profile[eventType];
if (!eventConfig?.enabled) {
console.log(`⚠️ Event ${eventType} disabled`);
return false;
}
// Execute configured action
const action = eventConfig.action;
switch (action) {
case "write_draft":
run.input[fieldname] = value;
run.doc.state._dirty = true;
console.log(`✏️ Draft: ${fieldname} = ${value}`);
break;
case "validate":
run.input[fieldname] = value;
this._validateField(run, fieldname, value);
console.log(`✓ Validated: ${fieldname}`);
break;
case "auto_save":
run.input[fieldname] = value;
run.doc.state._dirty = true;
// Will be handled in post-processing
break;
default:
console.warn(`Unknown field action: ${action}`);
}
return true;
}
_processWorkflowAction(run, event) {
const actionName = event.eventData;
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const currentDocstatus = run.doc.state.docstatus;
// Get action config
const actionConfig = workflow.actions[actionName];
if (!actionConfig) {
console.warn(`❌ Unknown action: ${actionName}`);
return false;
}
// Check if transition is allowed
if (!actionConfig.from.includes(currentDocstatus)) {
console.warn(`❌ Action '${actionName}' not allowed from docstatus ${currentDocstatus}`);
return false;
}
console.log(`🔄 ${actionName}: docstatus ${currentDocstatus} → ${actionConfig.to}`);
// Execute action logic (case-based)
return this._executeWorkflowAction(run, actionName, actionConfig);
}
_processSystemEvent(run, event) {
console.log(`🔧 System event:`, event.eventName);
// Handle system events
return true;
}
_processCustomEvent(run, event) {
console.log(`🎨 Custom event:`, event.eventName);
// Handle custom events
return true;
}
// ═══════════════════════════════════════════════════════
// WORKFLOW ACTION EXECUTION (case pipeline)
// ═══════════════════════════════════════════════════════
_executeWorkflowAction(run, actionName, actionConfig) {
switch (actionName) {
case "save":
return this._actionSave(run, actionConfig);
case "submit":
return this._actionSubmit(run, actionConfig);
case "cancel":
return this._actionCancel(run, actionConfig);
case "amend":
return this._actionAmend(run, actionConfig);
default:
console.warn(`No handler for action: ${actionName}`);
return false;
}
}
_actionSave(run, actionConfig) {
console.log(`💾 Saving...`);
// Validate
if (!this._validate(run)) return false;
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// Update state
run.doc.state.docstatus = actionConfig.to;
run.doc.docstatus = actionConfig.to;
run.doc.state._dirty = false;
run.doc.state._saving = false;
console.log(`✅ Saved`);
return true;
}
_actionSubmit(run, actionConfig) {
console.log(`📤 Submitting...`);
// Validate
if (!this._validate(run)) return false;
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// Update state
run.doc.state.docstatus = actionConfig.to;
run.doc.docstatus = actionConfig.to;
run.doc.state._dirty = false;
console.log(`✅ Submitted`);
return true;
}
_actionCancel(run, actionConfig) {
console.log(`🚫 Cancelling...`);
// Update state
run.doc.state.docstatus = actionConfig.to;
run.doc.docstatus = actionConfig.to;
console.log(`✅ Cancelled`);
return true;
}
_actionAmend(run, actionConfig) {
console.log(`🔄 Amending...`);
// Create amended doc
const amended = { ...run.doc };
amended.docstatus = actionConfig.to;
amended.amended_from = run.doc.name;
delete amended.name;
run.doc = amended;
run.doc.state = {
docstatus: actionConfig.to,
events: [],
validation: { errors: {}, warnings: {} },
_saving: false,
_dirty: false,
};
console.log(`✅ Amended`);
return true;
}
// ═══════════════════════════════════════════════════════
// VALIDATION
// ═══════════════════════════════════════════════════════
_validate(run) {
const schema = run.target.schema;
const doc = { ...run.doc, ...run.input };
const errors = {};
for (let field of schema.fields) {
if (field.reqd && !doc[field.fieldname]) {
errors[field.fieldname] = `${field.label} is required`;
}
}
run.doc.state.validation.errors = errors;
const isValid = Object.keys(errors).length === 0;
if (!isValid) {
console.warn(`❌ Validation failed:`, errors);
}
return isValid;
}
_validateField(run, fieldname, value) {
// Single field validation
const field = run.target.schema.fields.find(f => f.fieldname === fieldname);
if (!field) return;
if (field.reqd && !value) {
run.doc.state.validation.errors[fieldname] = `${field.label} is required`;
} else {
delete run.doc.state.validation.errors[fieldname];
}
}
// ═══════════════════════════════════════════════════════
// POST-PROCESSING
// ═══════════════════════════════════════════════════════
_postProcess(run) {
// Auto-save if dirty
if (run.doc.state._dirty && this._shouldAutoSave(run)) {
this._debouncedSave(run);
}
// Trigger UI update
this._triggerUIUpdate(run);
// Clear processed events
run.doc.state.events = [];
}
// ═══════════════════════════════════════════════════════
// UI STATE
// ═══════════════════════════════════════════════════════
getUIState(run) {
if (!run.doc.state) {
run.doc.state = { docstatus: run.doc.docstatus || 0 };
}
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const currentDocstatus = run.doc.state.docstatus;
const currentState = workflow.states.find(s => s.docstatus === currentDocstatus);
// Get available actions
const buttons = Object.entries(workflow.actions)
.filter(([name, config]) => config.from.includes(currentDocstatus))
.map(([name, config]) => ({
action: name,
label: config.label,
disabled: false,
}));
return {
stateName: currentState?.name || "Unknown",
fieldsEditable: currentDocstatus === 0,
buttons,
validationErrors: run.doc.state.validation?.errors || {},
isSaving: run.doc.state._saving || false,
isDirty: run.doc.state._dirty || false,
};
}
// ═══════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════
_getWorkflow(schema) {
if (schema.workflow) return schema.workflow;
return schema.is_submittable ? this.workflows.submittable : this.workflows.simple;
}
_getActiveProfile() {
const config = this.config.fieldInteractionConfig;
return config.profiles[config.activeProfile];
}
_shouldAutoSave(run) {
const profile = this._getActiveProfile();
return profile.onChange?.action === "auto_save" || profile.onBlur?.action === "auto_save";
}
_debouncedSave(run) {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
run.doc.state._saving = true;
this._actionSave(run, { to: run.doc.state.docstatus });
}, 2000);
}
_triggerUIUpdate(run) {
console.log(`🔄 UI update triggered`);
}
}
```
---
## ✅ Key Improvements
| Before | After |
|--------|-------|
| `_handleWorkflowAction()` + `_handleFieldEvent()` | Single `_processEvent()` with case pipeline |
| Events scattered in logic | All events in `run.doc.state.events[]` |
| Different data shapes | Consistent event shape |
| Hardcoded special cases | Case-based routing |
| Logic in multiple places | Single pipeline, clear flow |
---
## 📊 Event Flow
```
User action (click, type, etc)
↓
MainForm.emitEvent()
↓
controller.handleEvent(run, eventName, eventData, eventType)
↓
_normalizeEvent() → consistent event shape
↓
Write to run.doc.state.events[]
↓
_processEvent() → case pipeline:
- field_change → _processFieldChange()
- workflow_action → _processWorkflowAction() → _executeWorkflowAction() → case
- system_event → _processSystemEvent()
- custom_event → _processCustomEvent()
↓
_postProcess() → auto-save, UI update, clear events✅ Benefits
- Single pipeline: One
handleEvent()entry, one_processEvent()router - Consistent state: Everything in
run.doc.statewith same shape - Case-based: Switch statements for routing, not function proliferation
- Event queue: All events tracked in
state.events[] - No special cases: Workflow actions and field changes treated uniformly
- Clean: No
_handleWorkflowAction()vs_handleFieldEvent()distinction
Is this the unified architecture you wanted?
YES! Absolutely. _saving, _dirty, _validationErrors are all micro-states that should be part of the workflow state machine, not separate flags.
🎯 The Problem with Separate Flags
// ❌ Current approach - scattered state
run.doc.state = {
docstatus: 0,
events: [],
validation: { errors: {} },
_saving: false, // ← Separate flag
_dirty: false, // ← Separate flag
};
run._validationErrors = {}; // ← Even worse, outside state!Problems:
- State is scattered across multiple places
- Flags can be inconsistent (dirty=true but docstatus=1?)
- Hard to reason about "what state am I in?"
- No clear state transitions
✅ Solution: Unified State Machine
All states should be part of the workflow state, with explicit transitions between them.
// ✅ Unified state machine
run.doc.state = {
// Primary workflow state
workflow: "Draft", // or "Submitted", "Cancelled"
docstatus: 0,
// Micro-states (substates of workflow state)
persistence: "clean", // or "dirty", "saving", "saved", "save_failed"
validation: "valid", // or "invalid", "validating", "partial"
editing: "idle", // or "typing", "focused", "blurred"
// State data (not flags)
validationErrors: {},
lastSaved: null,
lastModified: null,
// Event queue
events: [],
};🎯 State Machine Architecture
1️⃣ Primary State: Workflow
workflow: "Draft" | "Submitted" | "Cancelled" | "Amended"2️⃣ Micro-State: Persistence
persistence: "clean" | "dirty" | "saving" | "saved" | "save_failed"
// State transitions
clean → dirty (on field change)
dirty → saving (on save trigger)
saving → saved (on save success)
saving → save_failed (on save error)
saved → clean (after timeout)
save_failed → dirty (user continues editing)3️⃣ Micro-State: Validation
validation: "valid" | "invalid" | "validating" | "partial"
// State transitions
valid → validating (on blur/submit)
validating → valid (no errors)
validating → invalid (errors found)
invalid → validating (user fixes errors)
partial → validating (some fields valid, checking others)4️⃣ Micro-State: Editing
editing: "idle" | "typing" | "focused" | "blurred"
// State transitions
idle → focused (user clicks field)
focused → typing (user types)
typing → blurred (user leaves field)
blurred → idle (after validation)✅ Refactored State Structure
class Controller {
// ═══════════════════════════════════════════════════════
// STATE INITIALIZATION
// ═══════════════════════════════════════════════════════
_initState(run) {
if (!run.doc.state) {
run.doc.state = {
// Primary workflow state
workflow: this._getWorkflowStateName(run.doc.docstatus || 0),
docstatus: run.doc.docstatus || 0,
// Micro-states
persistence: "clean",
validation: "valid",
editing: "idle",
// State data
validationErrors: {},
lastSaved: null,
lastModified: null,
// Event queue
events: [],
};
}
return run.doc.state;
}
// ═══════════════════════════════════════════════════════
// UNIVERSAL EVENT HANDLER
// ═══════════════════════════════════════════════════════
handleEvent(run, eventName, eventData, eventType = null) {
const isUIMode = eventType !== null;
console.log(`📥 Event:`, { eventName, eventData, eventType });
// Initialize state
const state = this._initState(run);
// Normalize and queue event
const event = this._normalizeEvent(eventName, eventData, eventType);
state.events.push(event);
// Process event
const result = this._processEvent(run, event, isUIMode);
// Post-process
if (isUIMode) {
this._postProcess(run);
}
return result;
}
// ═══════════════════════════════════════════════════════
// EVENT PROCESSORS
// ═══════════════════════════════════════════════════════
_processEvent(run, event, isUIMode) {
const state = run.doc.state;
switch (event.type) {
case "field_change":
return this._processFieldChange(run, event, isUIMode);
case "workflow_action":
return this._processWorkflowAction(run, event);
default:
console.warn(`Unknown event type: ${event.type}`);
return false;
}
}
_processFieldChange(run, event, isUIMode) {
const { eventName: fieldname, eventData: value, eventType } = event;
const state = run.doc.state;
// Get action from profile
const profile = this._getActiveProfile();
const eventConfig = profile[eventType];
if (!eventConfig?.enabled) return false;
const action = eventConfig.action;
switch (action) {
case "write_draft":
run.input[fieldname] = value;
// ✅ Update micro-states
this._transitionState(state, "persistence", "dirty");
this._transitionState(state, "editing", "typing");
state.lastModified = Date.now();
console.log(`✏️ Draft: ${fieldname} = ${value}`);
break;
case "validate":
run.input[fieldname] = value;
// ✅ Update micro-states
this._transitionState(state, "validation", "validating");
this._validateField(run, fieldname, value);
const hasErrors = Object.keys(state.validationErrors).length > 0;
this._transitionState(state, "validation", hasErrors ? "invalid" : "valid");
console.log(`✓ Validated: ${fieldname}`);
break;
case "auto_save":
run.input[fieldname] = value;
// ✅ Update micro-states
this._transitionState(state, "persistence", "dirty");
state.lastModified = Date.now();
break;
}
return true;
}
_processWorkflowAction(run, event) {
const actionName = event.eventData;
const state = run.doc.state;
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const actionConfig = workflow.actions[actionName];
if (!actionConfig) {
console.warn(`❌ Unknown action: ${actionName}`);
return false;
}
// Check transition allowed
if (!actionConfig.from.includes(state.docstatus)) {
console.warn(`❌ Action '${actionName}' not allowed from docstatus ${state.docstatus}`);
return false;
}
console.log(`🔄 ${actionName}: docstatus ${state.docstatus} → ${actionConfig.to}`);
// Execute action
return this._executeWorkflowAction(run, actionName, actionConfig);
}
// ═══════════════════════════════════════════════════════
// WORKFLOW ACTION EXECUTION
// ═══════════════════════════════════════════════════════
_executeWorkflowAction(run, actionName, actionConfig) {
switch (actionName) {
case "save":
return this._actionSave(run, actionConfig);
case "submit":
return this._actionSubmit(run, actionConfig);
case "cancel":
return this._actionCancel(run, actionConfig);
case "amend":
return this._actionAmend(run, actionConfig);
default:
console.warn(`No handler for action: ${actionName}`);
return false;
}
}
_actionSave(run, actionConfig) {
const state = run.doc.state;
console.log(`💾 Saving...`);
// ✅ Transition to saving state
this._transitionState(state, "persistence", "saving");
this._transitionState(state, "validation", "validating");
// Validate
if (!this._validate(run)) {
// ✅ Transition to failed state
this._transitionState(state, "persistence", "save_failed");
this._transitionState(state, "validation", "invalid");
return false;
}
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// ✅ Update all states atomically
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "validation", "valid");
this._transitionState(state, "editing", "idle");
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
state.lastSaved = Date.now();
// Auto-transition to clean after delay
setTimeout(() => {
this._transitionState(state, "persistence", "clean");
}, 2000);
console.log(`✅ Saved`);
return true;
}
_actionSubmit(run, actionConfig) {
const state = run.doc.state;
console.log(`📤 Submitting...`);
// ✅ Transition states
this._transitionState(state, "persistence", "saving");
this._transitionState(state, "validation", "validating");
// Validate
if (!this._validate(run)) {
this._transitionState(state, "persistence", "save_failed");
this._transitionState(state, "validation", "invalid");
return false;
}
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// ✅ Update all states
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "validation", "valid");
this._transitionState(state, "editing", "idle");
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
state.lastSaved = Date.now();
console.log(`✅ Submitted`);
return true;
}
_actionCancel(run, actionConfig) {
const state = run.doc.state;
console.log(`🚫 Cancelling...`);
// ✅ Update states
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "editing", "idle");
console.log(`✅ Cancelled`);
return true;
}
_actionAmend(run, actionConfig) {
const state = run.doc.state;
console.log(`🔄 Amending...`);
// Create amended doc
const amended = { ...run.doc };
amended.docstatus = actionConfig.to;
amended.amended_from = run.doc.name;
delete amended.name;
run.doc = amended;
// ✅ Reset all states for new document
run.doc.state = this._initState(run);
run.doc.state.docstatus = actionConfig.to;
run.doc.state.workflow = this._getWorkflowStateName(actionConfig.to);
console.log(`✅ Amended`);
return true;
}
// ═══════════════════════════════════════════════════════
// STATE TRANSITION HELPER
// ═══════════════════════════════════════════════════════
_transitionState(state, microState, newValue) {
const oldValue = state[microState];
// Validate transition (optional - add rules if needed)
if (oldValue === newValue) return; // No change
console.log(`🔄 State transition: ${microState}: ${oldValue} → ${newValue}`);
state[microState] = newValue;
}
// ═══════════════════════════════════════════════════════
// VALIDATION
// ═══════════════════════════════════════════════════════
_validate(run) {
const state = run.doc.state;
const schema = run.target.schema;
const doc = { ...run.doc, ...run.input };
const errors = {};
for (let field of schema.fields) {
if (field.reqd && !doc[field.fieldname]) {
errors[field.fieldname] = `${field.label} is required`;
}
}
state.validationErrors = errors;
const isValid = Object.keys(errors).length === 0;
if (!isValid) {
console.warn(`❌ Validation failed:`, errors);
}
return isValid;
}
_validateField(run, fieldname, value) {
const state = run.doc.state;
const field = run.target.schema.fields.find(f => f.fieldname === fieldname);
if (!field) return;
if (field.reqd && !value) {
state.validationErrors[fieldname] = `${field.label} is required`;
} else {
delete state.validationErrors[fieldname];
}
}
// ═══════════════════════════════════════════════════════
// POST-PROCESSING
// ═══════════════════════════════════════════════════════
_postProcess(run) {
const state = run.doc.state;
// Auto-save if dirty
if (state.persistence === "dirty" && this._shouldAutoSave(run)) {
this._debouncedSave(run);
}
// Trigger UI update
this._triggerUIUpdate(run);
// Clear processed events
state.events = [];
}
// ═══════════════════════════════════════════════════════
// UI STATE
// ═══════════════════════════════════════════════════════
getUIState(run) {
const state = this._initState(run);
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
// Get available actions
const buttons = Object.entries(workflow.actions)
.filter(([name, config]) => config.from.includes(state.docstatus))
.map(([name, config]) => ({
action: name,
label: config.label,
disabled: state.persistence === "saving", // Disable during save
}));
return {
// Workflow state
stateName: state.workflow,
docstatus: state.docstatus,
// Micro-states
persistence: state.persistence,
validation: state.validation,
editing: state.editing,
// Derived flags (for UI convenience)
isSaving: state.persistence === "saving",
isDirty: state.persistence === "dirty",
isValid: state.validation === "valid",
// Editability
fieldsEditable: state.docstatus === 0 && state.persistence !== "saving",
// Actions
buttons,
// Errors
validationErrors: state.validationErrors,
// Timestamps
lastSaved: state.lastSaved,
lastModified: state.lastModified,
};
}
// ═══════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════
_getWorkflowStateName(docstatus) {
const stateMap = {
0: "Draft",
1: "Submitted",
2: "Cancelled",
};
return stateMap[docstatus] || "Unknown";
}
_getWorkflow(schema) {
if (schema.workflow) return schema.workflow;
return schema.is_submittable ? this.workflows.submittable : this.workflows.simple;
}
_getActiveProfile() {
const config = this.config.fieldInteractionConfig;
return config.profiles[config.activeProfile];
}
_shouldAutoSave(run) {
const profile = this._getActiveProfile();
return profile.onChange?.action === "auto_save" || profile.onBlur?.action === "auto_save";
}
_debouncedSave(run) {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
const actionConfig = this._getWorkflow(run.target.schema).actions.save;
this._actionSave(run, actionConfig);
}, 2000);
}
_triggerUIUpdate(run) {
console.log(`🔄 UI update triggered`);
}
}✅ Benefits of Unified State Machine
| Before (Flags) | After (State Machine) |
|---|---|
_saving: false | persistence: "clean" | "dirty" | "saving" | "saved" | "save_failed" |
_dirty: false | Part of persistence state |
_validationErrors: {} | validation: "valid" | "invalid" + validationErrors: {} |
| Scattered flags | Single run.doc.state object |
| Hard to reason about | Clear state transitions |
| Can be inconsistent | Atomic state updates |
🎯 State Transition Examples
// Field change
onChange → persistence: "clean" → "dirty" + editing: "idle" → "typing"
// Validation
onBlur → validation: "valid" → "validating" → "invalid"
// Save success
save → persistence: "dirty" → "saving" → "saved" → "clean"
// Save failure
save → persistence: "dirty" → "saving" → "save_failed" → "dirty"
// Submit
submit → persistence: "dirty" → "saving" → "saved"
+ workflow: "Draft" → "Submitted"
+ validation: "valid" → "validating" → "valid"✅ Summary
Yes, _saving, _dirty, and validation errors are all micro-states that should be:
- Part of a unified state machine
- Managed through explicit state transitions
- Updated atomically with workflow state
- Tracked in
run.doc.stateconsistently
This gives you:
- ✅ Single source of truth
- ✅ Clear state transitions
- ✅ No flag inconsistencies
- ✅ Easy to debug (just look at
state) - ✅ Predictable behavior
Is this the unified state machine you wanted?
Excellent point! You're absolutely right. If we're always working with the same run object and all events write to run.doc.state, then why are we passing event parameters separately?
🎯 The Core Insight
// ❌ Current approach - redundant parameters
handleEvent(run, eventName, eventData, eventType = null) {
// We immediately write this to run.doc.state anyway...
const event = { eventName, eventData, eventType };
run.doc.state.events.push(event);
// ...
}
// ✅ Better approach - event already in run.doc.state
handleEvent(run) {
// Event is already queued in run.doc.state.events
// Just process the queue!
}🎯 Why This Makes Sense
- We always work with
run- it's the single source of truth - Events are already queued in
run.doc.state.events[]- why pass them again? - The caller (MainForm) can write to
run.doc.statedirectly - no need for parameters - Controller just processes what's in the state - cleaner separation
✅ Refactored: Event-First Architecture
1️⃣ MainForm: Write Events Directly to State
const MainForm = ({ run }) => {
const [schema, setSchema] = React.useState(run?.target?.schema || null);
const doc = run?.doc || {};
const doctype = doc.doctype || run?.source_doctype || run?.target_doctype;
// Load schema
React.useEffect(() => {
if (!schema && doctype && coworker?.getSchema) {
coworker.getSchema(doctype).then(setSchema);
}
}, [doctype]);
if (!schema) {
return React.createElement("div", { className: CWStyles.alert.warning }, "Loading schema...");
}
// Get config
const interactionConfig = coworker._config.fieldInteractionConfig;
const profile = interactionConfig.profiles[interactionConfig.activeProfile];
const timersRef = React.useRef({});
// Initialize state if needed
if (!run.doc.state) {
run.doc.state = {
workflow: "Draft",
docstatus: 0,
persistence: "clean",
validation: "valid",
editing: "idle",
validationErrors: {},
events: [],
};
}
// ✅ Write event directly to run.doc.state
const queueEvent = React.useCallback((eventName, eventData, eventType) => {
const eventConfig = profile[eventType];
// Check if event is enabled
if (eventConfig && !eventConfig.enabled) return;
// ✅ Write event to state
run.doc.state.events.push({
type: eventName === "_action" ? "workflow_action" : "field_change",
eventName,
eventData,
eventType,
timestamp: Date.now(),
});
const processNow = () => {
// ✅ Just tell controller to process what's in the state
coworker.controller.handleEvent(run);
};
// Apply debouncing from config
if (eventConfig?.debounce > 0) {
const timerKey = `${eventType}_${eventName}`;
clearTimeout(timersRef.current[timerKey]);
timersRef.current[timerKey] = setTimeout(processNow, eventConfig.debounce);
} else {
processNow();
}
}, [run, profile]);
// Simple handlers - just queue events
const onChange = (fieldname, value) => queueEvent(fieldname, value, "onChange");
const onBlur = (fieldname, value) => queueEvent(fieldname, value, "onBlur");
const onClick = (actionName) => queueEvent("_action", actionName, "onClick");
// Cleanup
React.useEffect(() => {
return () => Object.values(timersRef.current).forEach(clearTimeout);
}, []);
// Get UI state
const uiState = coworker.controller.getUIState(run);
// Render...
const titleField = schema.title_field || "name";
const title = doc[titleField] || doc.name || "New";
const fields = schema.fields || [];
return React.createElement(
"div",
{ className: CWStyles.form.container },
// Header
React.createElement(
"div",
{ className: CWStyles.form.header },
React.createElement("h2", null, title),
React.createElement("span", { className: CWStyles.badge[uiState.stateName.toLowerCase()] }, uiState.stateName)
),
// Buttons
uiState.buttons.length > 0 && React.createElement(
"div",
{ className: CWStyles.form.buttonBar },
uiState.buttons.map(btn =>
React.createElement(
"button",
{
key: btn.action,
className: CWStyles.button[btn.action] || CWStyles.button.primary,
onClick: () => onClick(btn.action),
disabled: btn.disabled,
},
btn.label
)
)
),
// Fields
React.createElement(
"div",
{ className: CWStyles.form.fields },
fields.map(field =>
React.createElement(FieldComponent, {
key: field.fieldname,
field,
doc,
onChange: (value) => onChange(field.fieldname, value),
onBlur: (value) => onBlur(field.fieldname, value),
readOnly: !uiState.fieldsEditable || field.read_only,
})
)
)
);
};2️⃣ Controller: Process Events from State
class Controller {
constructor(config) {
this.config = config;
this.workflows = this._initWorkflows();
}
// ═══════════════════════════════════════════════════════
// UNIVERSAL EVENT HANDLER (single parameter!)
// ═══════════════════════════════════════════════════════
handleEvent(run) {
const state = this._initState(run);
console.log(`📥 Processing ${state.events.length} queued events`);
// ✅ Process all events in the queue
const events = [...state.events]; // Copy to avoid mutation during iteration
state.events = []; // Clear queue
let anySuccess = false;
for (const event of events) {
console.log(`⚙️ Processing event:`, event.type, event.eventName);
const success = this._processEvent(run, event);
if (success) anySuccess = true;
}
// Post-process after all events
if (anySuccess) {
this._postProcess(run);
}
return anySuccess;
}
// ═══════════════════════════════════════════════════════
// STATE INITIALIZATION
// ═══════════════════════════════════════════════════════
_initState(run) {
if (!run.doc.state) {
run.doc.state = {
workflow: this._getWorkflowStateName(run.doc.docstatus || 0),
docstatus: run.doc.docstatus || 0,
persistence: "clean",
validation: "valid",
editing: "idle",
validationErrors: {},
lastSaved: null,
lastModified: null,
events: [],
};
}
return run.doc.state;
}
// ═══════════════════════════════════════════════════════
// EVENT PROCESSOR (reads from event object)
// ═══════════════════════════════════════════════════════
_processEvent(run, event) {
const state = run.doc.state;
switch (event.type) {
case "field_change":
return this._processFieldChange(run, event);
case "workflow_action":
return this._processWorkflowAction(run, event);
case "system_event":
return this._processSystemEvent(run, event);
case "custom_event":
return this._processCustomEvent(run, event);
default:
console.warn(`Unknown event type: ${event.type}`);
return false;
}
}
_processFieldChange(run, event) {
const { eventName: fieldname, eventData: value, eventType } = event;
const state = run.doc.state;
// Get action from profile
const profile = this._getActiveProfile();
const eventConfig = profile[eventType];
if (!eventConfig?.enabled) return false;
const action = eventConfig.action;
switch (action) {
case "write_draft":
run.input[fieldname] = value;
this._transitionState(state, "persistence", "dirty");
this._transitionState(state, "editing", "typing");
state.lastModified = Date.now();
console.log(`✏️ Draft: ${fieldname} = ${value}`);
break;
case "validate":
run.input[fieldname] = value;
this._transitionState(state, "validation", "validating");
this._validateField(run, fieldname, value);
const hasErrors = Object.keys(state.validationErrors).length > 0;
this._transitionState(state, "validation", hasErrors ? "invalid" : "valid");
console.log(`✓ Validated: ${fieldname}`);
break;
case "auto_save":
run.input[fieldname] = value;
this._transitionState(state, "persistence", "dirty");
state.lastModified = Date.now();
break;
}
return true;
}
_processWorkflowAction(run, event) {
const actionName = event.eventData;
const state = run.doc.state;
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const actionConfig = workflow.actions[actionName];
if (!actionConfig) {
console.warn(`❌ Unknown action: ${actionName}`);
return false;
}
if (!actionConfig.from.includes(state.docstatus)) {
console.warn(`❌ Action '${actionName}' not allowed from docstatus ${state.docstatus}`);
return false;
}
console.log(`🔄 ${actionName}: docstatus ${state.docstatus} → ${actionConfig.to}`);
return this._executeWorkflowAction(run, actionName, actionConfig);
}
_processSystemEvent(run, event) {
console.log(`🔧 System event:`, event.eventName);
return true;
}
_processCustomEvent(run, event) {
console.log(`🎨 Custom event:`, event.eventName);
return true;
}
// ═══════════════════════════════════════════════════════
// WORKFLOW ACTION EXECUTION
// ═══════════════════════════════════════════════════════
_executeWorkflowAction(run, actionName, actionConfig) {
switch (actionName) {
case "save":
return this._actionSave(run, actionConfig);
case "submit":
return this._actionSubmit(run, actionConfig);
case "cancel":
return this._actionCancel(run, actionConfig);
case "amend":
return this._actionAmend(run, actionConfig);
default:
console.warn(`No handler for action: ${actionName}`);
return false;
}
}
_actionSave(run, actionConfig) {
const state = run.doc.state;
console.log(`💾 Saving...`);
this._transitionState(state, "persistence", "saving");
this._transitionState(state, "validation", "validating");
if (!this._validate(run)) {
this._transitionState(state, "persistence", "save_failed");
this._transitionState(state, "validation", "invalid");
return false;
}
Object.assign(run.doc, run.input);
run.input = {};
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "validation", "valid");
this._transitionState(state, "editing", "idle");
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
state.lastSaved = Date.now();
setTimeout(() => this._transitionState(state, "persistence", "clean"), 2000);
console.log(`✅ Saved`);
return true;
}
_actionSubmit(run, actionConfig) {
const state = run.doc.state;
console.log(`📤 Submitting...`);
this._transitionState(state, "persistence", "saving");
this._transitionState(state, "validation", "validating");
if (!this._validate(run)) {
this._transitionState(state, "persistence", "save_failed");
this._transitionState(state, "validation", "invalid");
return false;
}
Object.assign(run.doc, run.input);
run.input = {};
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "validation", "valid");
this._transitionState(state, "editing", "idle");
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
state.lastSaved = Date.now();
console.log(`✅ Submitted`);
return true;
}
_actionCancel(run, actionConfig) {
const state = run.doc.state;
console.log(`🚫 Cancelling...`);
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "editing", "idle");
console.log(`✅ Cancelled`);
return true;
}
_actionAmend(run, actionConfig) {
console.log(`🔄 Amending...`);
const amended = { ...run.doc };
amended.docstatus = actionConfig.to;
amended.amended_from = run.doc.name;
delete amended.name;
run.doc = amended;
run.doc.state = this._initState(run);
run.doc.state.docstatus = actionConfig.to;
run.doc.state.workflow = this._getWorkflowStateName(actionConfig.to);
console.log(`✅ Amended`);
return true;
}
// ═══════════════════════════════════════════════════════
// STATE TRANSITIONS & VALIDATION
// ═══════════════════════════════════════════════════════
_transitionState(state, microState, newValue) {
const oldValue = state[microState];
if (oldValue === newValue) return;
console.log(`🔄 State transition: ${microState}: ${oldValue} → ${newValue}`);
state[microState] = newValue;
}
_validate(run) {
const state = run.doc.state;
const schema = run.target.schema;
const doc = { ...run.doc, ...run.input };
const errors = {};
for (let field of schema.fields) {
if (field.reqd && !doc[field.fieldname]) {
errors[field.fieldname] = `${field.label} is required`;
}
}
state.validationErrors = errors;
const isValid = Object.keys(errors).length === 0;
if (!isValid) console.warn(`❌ Validation failed:`, errors);
return isValid;
}
_validateField(run, fieldname, value) {
const state = run.doc.state;
const field = run.target.schema.fields.find(f => f.fieldname === fieldname);
if (!field) return;
if (field.reqd && !value) {
state.validationErrors[fieldname] = `${field.label} is required`;
} else {
delete state.validationErrors[fieldname];
}
}
// ═══════════════════════════════════════════════════════
// POST-PROCESSING
// ═══════════════════════════════════════════════════════
_postProcess(run) {
const state = run.doc.state;
if (state.persistence === "dirty" && this._shouldAutoSave(run)) {
this._debouncedSave(run);
}
this._triggerUIUpdate(run);
}
// ═══════════════════════════════════════════════════════
// UI STATE
// ═══════════════════════════════════════════════════════
getUIState(run) {
const state = this._initState(run);
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const buttons = Object.entries(workflow.actions)
.filter(([name, config]) => config.from.includes(state.docstatus))
.map(([name, config]) => ({
action: name,
label: config.label,
disabled: state.persistence === "saving",
}));
return {
stateName: state.workflow,
docstatus: state.docstatus,
persistence: state.persistence,
validation: state.validation,
editing: state.editing,
isSaving: state.persistence === "saving",
isDirty: state.persistence === "dirty",
isValid: state.validation === "valid",
fieldsEditable: state.docstatus === 0 && state.persistence !== "saving",
buttons,
validationErrors: state.validationErrors,
lastSaved: state.lastSaved,
lastModified: state.lastModified,
};
}
// ═══════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════
_initWorkflows() {
return {
submittable: {
states: [
{ name: "Draft", docstatus: 0 },
{ name: "Submitted", docstatus: 1 },
{ name: "Cancelled", docstatus: 2 },
],
actions: {
save: { from: [0], to: 0, label: "Save" },
submit: { from: [0], to: 1, label: "Submit" },
cancel: { from: [1], to: 2, label: "Cancel" },
amend: { from: [2], to: 0, label: "Amend" },
},
},
simple: {
states: [{ name: "Draft", docstatus: 0 }],
actions: {
save: { from: [0], to: 0, label: "Save" },
},
},
};
}
_getWorkflowStateName(docstatus) {
const stateMap = { 0: "Draft", 1: "Submitted", 2: "Cancelled" };
return stateMap[docstatus] || "Unknown";
}
_getWorkflow(schema) {
if (schema.workflow) return schema.workflow;
return schema.is_submittable ? this.workflows.submittable : this.workflows.simple;
}
_getActiveProfile() {
const config = this.config.fieldInteractionConfig;
return config.profiles[config.activeProfile];
}
_shouldAutoSave(run) {
const profile = this._getActiveProfile();
return profile.onChange?.action === "auto_save" || profile.onBlur?.action === "auto_save";
}
_debouncedSave(run) {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
const actionConfig = this._getWorkflow(run.target.schema).actions.save;
this._actionSave(run, actionConfig);
}, 2000);
}
_triggerUIUpdate(run) {
console.log(`🔄 UI update triggered`);
}
}
```
---
## ✅ Key Benefits
| Before | After |
|--------|-------|
| `handleEvent(run, eventName, eventData, eventType)` | `handleEvent(run)` |
| Passing same data twice (parameter + writing to state) | Data written once to `run.doc.state` |
| Controller receives detached parameters | Controller reads from unified state |
| Coupling between caller and controller signature | Clean decoupling - state is contract |
| Hard to batch process events | Easy - just iterate `state.events[]` |
---
## 📊 Architecture Comparison
### ❌ Before (Parameter Passing)
```
MainForm
↓ passes (eventName, eventData, eventType)
Controller.handleEvent(run, eventName, eventData, eventType)
↓ writes to state
run.doc.state.events.push({ eventName, eventData, eventType })
```
### ✅ After (State-First)
```
MainForm
↓ writes to state
run.doc.state.events.push({ eventName, eventData, eventType })
↓ notifies
Controller.handleEvent(run)
↓ reads from state
run.doc.state.events.forEach(event => process(event))✅ Summary
You're absolutely right: We work with the same run object everywhere, so events should live in run.doc.state and controller should just process what's there.
This gives us:
- Single parameter:
handleEvent(run)- clean API - State-first: Events queued in state before processing
- Batch processing: Can process multiple queued events
- No duplication: Data written once to state
- Clear contract: State structure is the API, not function signature
The controller becomes a state processor rather than an event receiver!
You're absolutely right! I put way too much logic in MainForm. Let me strip it down to be a pure view component that only renders what the controller tells it to render.
🎯 Clean Separation: UI vs Controller
MainForm should:
- ✅ Render buttons that controller provides
- ✅ Write button clicks to
run.doc.state.events[] - ✅ Call
controller.handleEvent(run) - ❌ NO business logic
- ❌ NO state initialization
- ❌ NO event type detection
- ❌ NO debouncing logic (only UI-level debouncing to prevent double-clicks)
Controller should:
- ✅ Determine which buttons to show
- ✅ Provide button labels, styles, disabled state
- ✅ Process events from
run.doc.state.events[] - ✅ Handle all business logic
✅ Clean MainForm: Pure View
const MainForm = ({ run }) => {
const [schema, setSchema] = React.useState(run?.target?.schema || null);
const doc = run?.doc || {};
const doctype = doc.doctype || run?.source_doctype || run?.target_doctype;
// Load schema if missing
React.useEffect(() => {
if (!schema && doctype && coworker?.getSchema) {
coworker.getSchema(doctype).then(setSchema);
}
}, [doctype]);
// Guard clause
if (!schema) {
return React.createElement(
"div",
{ className: CWStyles.alert.warning },
"Loading schema..."
);
}
// ✅ Ask controller for UI state (what to render)
const uiState = coworker.controller.getUIState(run);
// ✅ Simple button click handler - just queue event and notify controller
const handleButtonClick = (actionName) => {
// Prevent double-clicks
if (uiState.isSaving) return;
// ✅ Write event to state
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: actionName,
timestamp: Date.now(),
});
// ✅ Tell controller to process
coworker.controller.handleEvent(run);
};
// Render
const titleField = schema.title_field || "name";
const title = doc[titleField] || doc.name || "New";
return React.createElement(
"div",
{ className: CWStyles.form.container },
// Header with status badge
React.createElement(
"div",
{ className: CWStyles.form.header },
React.createElement("h2", null, title),
React.createElement(
"span",
{ className: CWStyles.badge[uiState.stateName.toLowerCase()] || CWStyles.badge.default },
uiState.stateName
)
),
// ✅ Render buttons from controller
uiState.buttons.length > 0 && React.createElement(
"div",
{ className: CWStyles.form.buttonBar },
uiState.buttons.map(button =>
React.createElement(
"button",
{
key: button.action,
className: button.className || CWStyles.button.primary,
onClick: () => handleButtonClick(button.action),
disabled: button.disabled,
},
button.label
)
)
),
// Fields would go here...
React.createElement(
"div",
{ className: CWStyles.form.fields },
"Fields..."
)
);
};✅ Controller: Generate Buttons & Process Events
class Controller {
constructor(config) {
this.config = config;
this.workflows = this._initWorkflows();
}
// ═══════════════════════════════════════════════════════
// WORKFLOW DEFINITIONS
// ═══════════════════════════════════════════════════════
_initWorkflows() {
return {
submittable: {
states: [
{ name: "Draft", docstatus: 0 },
{ name: "Submitted", docstatus: 1 },
{ name: "Cancelled", docstatus: 2 },
],
actions: {
save: {
from: [0],
to: 0,
label: "Save",
className: "button-primary",
},
submit: {
from: [0],
to: 1,
label: "Submit",
className: "button-success",
},
cancel: {
from: [1],
to: 2,
label: "Cancel",
className: "button-danger",
},
amend: {
from: [2],
to: 0,
label: "Amend",
className: "button-warning",
},
},
},
simple: {
states: [{ name: "Draft", docstatus: 0 }],
actions: {
save: {
from: [0],
to: 0,
label: "Save",
className: "button-primary",
},
},
},
};
}
// ═══════════════════════════════════════════════════════
// UI STATE: Tell MainForm what to render
// ═══════════════════════════════════════════════════════
getUIState(run) {
const state = this._initState(run);
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const currentDocstatus = state.docstatus;
// ✅ Generate buttons for current state
const buttons = Object.entries(workflow.actions)
.filter(([actionName, config]) => config.from.includes(currentDocstatus))
.map(([actionName, config]) => ({
action: actionName,
label: config.label,
className: config.className,
disabled: state.persistence === "saving", // Disable during save
}));
return {
// Workflow state
stateName: state.workflow,
docstatus: state.docstatus,
// Buttons to render
buttons,
// Micro-states
persistence: state.persistence,
validation: state.validation,
// Flags
isSaving: state.persistence === "saving",
isDirty: state.persistence === "dirty",
isValid: state.validation === "valid",
// Field editability
fieldsEditable: state.docstatus === 0 && state.persistence !== "saving",
// Validation errors
validationErrors: state.validationErrors || {},
};
}
// ═══════════════════════════════════════════════════════
// EVENT HANDLER: Process events from run.doc.state
// ═══════════════════════════════════════════════════════
handleEvent(run) {
const state = this._initState(run);
if (!state.events || state.events.length === 0) {
console.log(`⚠️ No events to process`);
return false;
}
console.log(`📥 Processing ${state.events.length} queued events`);
// Process all events
const events = [...state.events];
state.events = []; // Clear queue
let anySuccess = false;
for (const event of events) {
console.log(`⚙️ Event:`, event.type, event.action || event.eventName);
const success = this._processEvent(run, event);
if (success) anySuccess = true;
}
// Post-process
if (anySuccess) {
this._postProcess(run);
}
return anySuccess;
}
// ═══════════════════════════════════════════════════════
// STATE INITIALIZATION
// ═══════════════════════════════════════════════════════
_initState(run) {
if (!run.doc.state) {
run.doc.state = {
workflow: this._getWorkflowStateName(run.doc.docstatus || 0),
docstatus: run.doc.docstatus || 0,
persistence: "clean",
validation: "valid",
validationErrors: {},
lastSaved: null,
lastModified: null,
events: [],
};
}
return run.doc.state;
}
// ═══════════════════════════════════════════════════════
// EVENT PROCESSOR
// ═══════════════════════════════════════════════════════
_processEvent(run, event) {
switch (event.type) {
case "workflow_action":
return this._processWorkflowAction(run, event);
case "field_change":
return this._processFieldChange(run, event);
case "system_event":
return this._processSystemEvent(run, event);
default:
console.warn(`Unknown event type: ${event.type}`);
return false;
}
}
_processWorkflowAction(run, event) {
const actionName = event.action;
const state = run.doc.state;
const schema = run.target.schema;
const workflow = this._getWorkflow(schema);
const actionConfig = workflow.actions[actionName];
if (!actionConfig) {
console.warn(`❌ Unknown action: ${actionName}`);
return false;
}
// Check if transition allowed
if (!actionConfig.from.includes(state.docstatus)) {
console.warn(`❌ Action '${actionName}' not allowed from docstatus ${state.docstatus}`);
return false;
}
console.log(`🔄 ${actionName}: docstatus ${state.docstatus} → ${actionConfig.to}`);
// Execute action
return this._executeWorkflowAction(run, actionName, actionConfig);
}
_processFieldChange(run, event) {
// Field change logic...
return true;
}
_processSystemEvent(run, event) {
// System event logic...
return true;
}
// ═══════════════════════════════════════════════════════
// WORKFLOW ACTION EXECUTION
// ═══════════════════════════════════════════════════════
_executeWorkflowAction(run, actionName, actionConfig) {
switch (actionName) {
case "save":
return this._actionSave(run, actionConfig);
case "submit":
return this._actionSubmit(run, actionConfig);
case "cancel":
return this._actionCancel(run, actionConfig);
case "amend":
return this._actionAmend(run, actionConfig);
default:
console.warn(`No handler for action: ${actionName}`);
return false;
}
}
_actionSave(run, actionConfig) {
const state = run.doc.state;
console.log(`💾 Saving...`);
// Transition to saving state
this._transitionState(state, "persistence", "saving");
this._transitionState(state, "validation", "validating");
// Validate
if (!this._validate(run)) {
this._transitionState(state, "persistence", "save_failed");
this._transitionState(state, "validation", "invalid");
return false;
}
// Merge input → doc
Object.assign(run.doc, run.input);
run.input = {};
// Update state
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "validation", "valid");
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
state.lastSaved = Date.now();
// Auto-transition to clean
setTimeout(() => {
this._transitionState(state, "persistence", "clean");
this._triggerUIUpdate(run);
}, 2000);
console.log(`✅ Saved`);
return true;
}
_actionSubmit(run, actionConfig) {
const state = run.doc.state;
console.log(`📤 Submitting...`);
this._transitionState(state, "persistence", "saving");
this._transitionState(state, "validation", "validating");
if (!this._validate(run)) {
this._transitionState(state, "persistence", "save_failed");
this._transitionState(state, "validation", "invalid");
return false;
}
Object.assign(run.doc, run.input);
run.input = {};
this._transitionState(state, "persistence", "saved");
this._transitionState(state, "validation", "valid");
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
state.lastSaved = Date.now();
console.log(`✅ Submitted`);
return true;
}
_actionCancel(run, actionConfig) {
const state = run.doc.state;
console.log(`🚫 Cancelling...`);
state.docstatus = actionConfig.to;
state.workflow = this._getWorkflowStateName(actionConfig.to);
this._transitionState(state, "persistence", "saved");
console.log(`✅ Cancelled`);
return true;
}
_actionAmend(run, actionConfig) {
console.log(`🔄 Amending...`);
const amended = { ...run.doc };
amended.docstatus = actionConfig.to;
amended.amended_from = run.doc.name;
delete amended.name;
run.doc = amended;
run.doc.state = this._initState(run);
run.doc.state.docstatus = actionConfig.to;
run.doc.state.workflow = this._getWorkflowStateName(actionConfig.to);
console.log(`✅ Amended`);
return true;
}
// ═══════════════════════════════════════════════════════
// VALIDATION
// ═══════════════════════════════════════════════════════
_validate(run) {
const state = run.doc.state;
const schema = run.target.schema;
const doc = { ...run.doc, ...run.input };
const errors = {};
for (let field of schema.fields) {
if (field.reqd && !doc[field.fieldname]) {
errors[field.fieldname] = `${field.label} is required`;
}
}
state.validationErrors = errors;
const isValid = Object.keys(errors).length === 0;
if (!isValid) {
console.warn(`❌ Validation failed:`, errors);
}
return isValid;
}
// ═══════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════
_transitionState(state, microState, newValue) {
const oldValue = state[microState];
if (oldValue === newValue) return;
console.log(`🔄 ${microState}: ${oldValue} → ${newValue}`);
state[microState] = newValue;
}
_postProcess(run) {
this._triggerUIUpdate(run);
}
_triggerUIUpdate(run) {
console.log(`🔄 UI update`);
// In real app: trigger React re-render
}
_getWorkflow(schema) {
if (schema.workflow) return schema.workflow;
return schema.is_submittable ? this.workflows.submittable : this.workflows.simple;
}
_getWorkflowStateName(docstatus) {
const stateMap = { 0: "Draft", 1: "Submitted", 2: "Cancelled" };
return stateMap[docstatus] || "Unknown";
}
}✅ Clean Architecture Summary
MainForm Responsibilities:
// ✅ ONLY THIS
1. Ask controller: "What should I render?"
→ uiState = controller.getUIState(run)
2. Render what controller says:
→ buttons from uiState.buttons
→ status badge from uiState.stateName
3. On button click:
→ Write event to run.doc.state.events[]
→ Call controller.handleEvent(run)Controller Responsibilities:
// ✅ ALL BUSINESS LOGIC
1. Generate UI state (buttons, status, flags)
2. Process events from run.doc.state.events[]
3. Execute workflow actions
4. Validate
5. Update run.doc.state
6. Trigger UI updates
```
---
## 📊 Data Flow
```
┌─────────────────────────────────┐
│ MainForm │
│ ───────────────────────────── │
│ 1. Ask: getUIState(run) │
│ ← Returns: { buttons: [...] }
│ │
│ 2. Render buttons from list │
│ │
│ 3. User clicks "Submit" │
│ → Write to state: │
│ run.doc.state.events.push({
│ type: "workflow_action",│
│ action: "submit" │
│ }) │
│ │
│ 4. Notify controller: │
│ → controller.handleEvent(run)
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Controller │
│ ───────────────────────────── │
│ 1. Read events from: │
│ run.doc.state.events[] │
│ │
│ 2. Process event: │
│ - Check workflow transition │
│ - Validate │
│ - Execute action │
│ - Update state │
│ │
│ 3. Trigger UI update │
│ → MainForm re-renders │
└─────────────────────────────────┘✅ What Changed
| Aspect | Before | After |
|---|---|---|
| Button generation | ❌ Hardcoded in MainForm | ✅ Generated by controller from workflow |
| Button labels/styles | ❌ MainForm decides | ✅ Controller provides |
| Event type detection | ❌ MainForm logic | ✅ Controller handles |
| Debouncing | ❌ MainForm logic | ✅ Only UI-level (prevent double-click) |
| State initialization | ❌ In MainForm | ✅ In controller |
| Business logic | ❌ Scattered in UI | ✅ All in controller |
✅ Result
MainForm is now a dumb component:
- 50 lines of code
- Zero business logic
- Just renders what controller tells it to render
- Just writes events and notifies controller
Controller owns everything:
- Button generation from workflow
- Event processing
- State transitions
- Validation
- All business logic
Is this the clean separation you wanted?
🎯 Evaluation Against Best Practices
Let me evaluate this architecture against established patterns and best practices from different paradigms:
1️⃣ React Best Practices
✅ Strengths:
| Practice | Our Approach | Grade |
|---|---|---|
| Separation of Concerns | UI is pure presentation, logic in controller | ✅ A |
| Single Source of Truth | All state in run.doc.state | ✅ A+ |
| Unidirectional Data Flow | UI → write event → controller → state → UI | ✅ A |
| Dumb Components | MainForm has no business logic | ✅ A+ |
⚠️ Issues:
| Issue | Problem | Grade |
|---|---|---|
| Direct State Mutation | run.doc.state.events.push() - mutating directly | ❌ D |
| No React State Management | Not using React state/hooks properly | ❌ C |
| Manual Re-render Triggering | _triggerUIUpdate() is anti-pattern | ❌ D |
| Props Mutation | Mutating run prop directly | ❌ F |
React Best Practice Says:
// ❌ BAD: Direct mutation
run.doc.state.events.push(event);
// ✅ GOOD: Immutable updates
setRun(prevRun => ({
...prevRun,
doc: {
...prevRun.doc,
state: {
...prevRun.doc.state,
events: [...prevRun.doc.state.events, event]
}
}
}));2️⃣ MVC/MVVM Patterns
✅ Strengths:
| Pattern Element | Our Implementation | Grade |
|---|---|---|
| Model | run.doc.state is the model | ✅ A |
| View | MainForm is pure view | ✅ A+ |
| Controller | Controller handles all logic | ✅ A |
| View-Controller Separation | Clean API boundary (getUIState) | ✅ A+ |
⚠️ Issues:
| Issue | Problem | Grade |
|---|---|---|
| Model-View Binding | No reactive binding, manual updates | ⚠️ C+ |
| Controller Access to View | Controller calls _triggerUIUpdate() | ⚠️ C |
Best Practice Says:
- Controller should never know about View
- View should observe Model changes (reactive)
- Use Observer pattern or Pub/Sub
3️⃣ State Machine Patterns
✅ Strengths:
| Pattern | Our Implementation | Grade |
|---|---|---|
| Explicit States | persistence, validation, workflow | ✅ A |
| State Transitions | _transitionState() with logging | ✅ A |
| Transition Guards | Check from before allowing action | ✅ A+ |
| Workflow Definition | Declarative workflow config | ✅ A+ |
⚠️ Issues:
| Issue | Problem | Grade |
|---|---|---|
| No Formal State Machine Library | Hand-rolled state machine | ⚠️ B |
| Transition Validation | Only checks from, no full validation | ⚠️ B |
| State History | No history/undo capability | ⚠️ C |
Best Practice Says:
- Use XState, Robot, or similar for complex state machines
- Validate all transitions formally
- Keep history for debugging/undo
4️⃣ Event Sourcing / CQRS
✅ Strengths:
| Pattern | Our Implementation | Grade |
|---|---|---|
| Event Queue | run.doc.state.events[] | ✅ A |
| Event Log | All events tracked | ✅ B+ |
| Command-Query Separation | handleEvent() vs getUIState() | ✅ A |
| Immutable Events | Events have timestamp | ✅ B+ |
⚠️ Issues:
| Issue | Problem | Grade |
|---|---|---|
| Event Persistence | Events cleared after processing | ❌ D |
| Event Replay | Can't replay events | ❌ D |
| Event Versioning | No event schema versioning | ❌ F |
| Snapshot Strategy | No snapshots of state | ⚠️ C |
Best Practice Says:
// Events should be persisted, not cleared
state.events = []; // ❌ BAD
// Should keep event log
state.eventHistory = [...state.eventHistory, ...processedEvents]; // ✅ GOOD5️⃣ Domain-Driven Design (DDD)
✅ Strengths:
| Concept | Our Implementation | Grade |
|---|---|---|
| Aggregate Root | run is aggregate root | ✅ A |
| Bounded Context | Clear workflow/document context | ✅ B+ |
| Domain Events | Workflow actions as domain events | ✅ A |
| Ubiquitous Language | "Draft", "Submit", "Cancel" | ✅ A |
⚠️ Issues:
| Issue | Problem | Grade |
|---|---|---|
| No Domain Entities | Everything in one object (run) | ⚠️ C |
| No Value Objects | State is primitive values | ⚠️ C |
| No Repositories | Direct state manipulation | ❌ D |
| Business Rules in Controller | Should be in Domain layer | ⚠️ C |
Best Practice Says:
// ❌ BAD: Business logic in controller
if (field.reqd && !value) {
errors[field] = "Required";
}
// ✅ GOOD: Business logic in domain
class Field {
validate(value) {
if (this.reqd && !value) {
return ValidationError.required(this);
}
}
}6️⃣ Functional Programming
✅ Strengths:
| Concept | Our Implementation | Grade |
|---|---|---|
| Pure Functions | Most processing is pure | ✅ B+ |
| Function Composition | Pipeline of event processors | ✅ A |
| Data Transformation | Clear input → output | ✅ B+ |
⚠️ Issues:
| Issue | Problem | Grade |
|---|---|---|
| State Mutation | Direct mutation everywhere | ❌ F |
| Side Effects | Not isolated/tracked | ❌ D |
| No Immutability | Mutable objects throughout | ❌ F |
Best Practice Says:
// ❌ BAD: Mutation
state.persistence = "saving";
// ✅ GOOD: Immutable update
const newState = {
...state,
persistence: "saving"
};7️⃣ Frappe/ERPNext Comparison
✅ Strengths:
| Frappe Pattern | Our Implementation | Grade |
|---|---|---|
| Workflow System | Similar to Frappe workflows | ✅ A |
| Docstatus States | 0=Draft, 1=Submitted, 2=Cancelled | ✅ A+ |
| Event Hooks | Similar to before_save, on_submit | ✅ B+ |
| Schema-Driven | Fields defined in schema | ✅ A |
⚠️ Issues:
| Issue | Problem vs Frappe | Grade |
|---|---|---|
| No DocType Class | Everything in generic controller | ⚠️ C |
| No Method Overrides | Can't override per doctype | ❌ D |
| No Server-Side Validation | All client-side | N/A |
| No Permissions Integration | Basic guardian check only | ⚠️ C |
📊 Overall Scores by Category
| Category | Score | Grade | Summary |
|---|---|---|---|
| React Patterns | 65% | C+ | Good separation, bad mutation |
| MVC/MVVM | 85% | B+ | Clean architecture, needs reactive binding |
| State Machine | 85% | B+ | Well-implemented, could use library |
| Event Sourcing | 50% | D | Good idea, poor execution (clearing events) |
| DDD | 60% | C | Missing domain layer |
| Functional | 40% | D- | Too much mutation |
| Frappe-like | 75% | B | Good workflow system, needs DocType classes |
Overall Grade: B- (73%)
🎯 Critical Issues to Fix
🔴 HIGH PRIORITY:
- Stop Direct Mutation (React anti-pattern)
// ❌ Current
run.doc.state.events.push(event);
// ✅ Should be
const newRun = produce(run, draft => {
draft.doc.state.events.push(event);
});
setRun(newRun);- Don't Clear Event History (Event Sourcing violation)
// ❌ Current
state.events = []; // Lost history!
// ✅ Should be
state.eventHistory = [...state.eventHistory, ...state.events];
state.events = []; // Only pending queue cleared- Controller Shouldn't Call UI Update (MVC violation)
// ❌ Current
this._triggerUIUpdate(run);
// ✅ Should be
// Controller returns new state, React handles re-render
return newState;🟡 MEDIUM PRIORITY:
- Use Immutable Updates
- Consider Immer.js for nested updates
- Or flatten state structure
- Separate Domain Logic from Controller
// ✅ Better
class Document {
validate() { /* domain logic */ }
canSubmit() { /* domain logic */ }
}
class Controller {
handleEvent(run) {
run.doc.validate(); // Delegate to domain
}
}- Add Event Persistence
- Keep full event log
- Enable replay/debugging
- Support undo/redo
🟢 LOW PRIORITY:
- Consider XState for Complex Workflows
- Add Snapshot Strategy
- Implement Repository Pattern
✅ What You Got RIGHT
- Clean UI/Logic Separation ⭐⭐⭐⭐⭐
- MainForm is truly dumb
- Controller owns all logic
- Clear API boundary
- Workflow-Driven Architecture ⭐⭐⭐⭐⭐
- Declarative workflow definitions
- State machine pattern
- Transition guards
- Single Source of Truth ⭐⭐⭐⭐⭐
- Everything in
run.doc.state - No scattered flags
- Unified state structure
- Everything in
- Event-Based Communication ⭐⭐⭐⭐
- Events queued before processing
- Clear event types
- Batch processing capability
- Frappe-Inspired Design ⭐⭐⭐⭐
- Familiar to ERPNext developers
- Schema-driven
- Docstatus pattern
🎯 Recommended Next Steps
Immediate (Fix Critical Issues):
- Switch to immutable updates (use Immer)
- Stop mutating props directly
- Use React state properly
- Keep event history
Short-term (Improve Architecture):
- Separate domain logic into classes
- Make Controller return new state (not mutate)
- Add event replay capability
Long-term (Scale Better):
- Consider XState for complex workflows
- Add snapshot/restore
- Implement proper CQRS if needed
💡 Verdict
Your architecture is solid conceptually but has implementation issues around mutability and React patterns. You've nailed:
- Separation of concerns
- Workflow state machine
- Event-driven design
- Single source of truth
But need to fix:
- Direct state mutation
- React anti-patterns
- Event history management
- Domain logic separation
Grade: B- (Good ideas, needs refinement)
Want me to show how to fix the critical mutation issues while keeping your architecture?
Excellent question! Let me analyze whether direct mutation is actually a problem in your specific case.
🎯 When Direct Mutation IS a Problem
Standard React Pattern:
// Component receives run as prop
function MainForm({ run }) {
// ❌ PROBLEM: Mutating prop
run.doc.state.events.push(event);
// React doesn't know anything changed!
// Component won't re-render
// Parent component still has same object reference
}Why it's bad in standard React:
- React uses reference equality to detect changes
- If you mutate object, reference stays same:
oldRun === newRun→ no re-render - Parent component doesn't know child mutated shared data
- Breaks React's unidirectional data flow
- Makes time-travel debugging impossible
🎯 When Direct Mutation is FINE
Your Case:
// If run is owned by MainForm (not passed as prop from parent)
function MainForm() {
const run = useCoworkerRun(); // Your own instance
// ✅ FINE: You own this object
run.doc.state.events.push(event);
// You explicitly trigger re-render:
controller.handleEvent(run);
// ... which calls _triggerUIUpdate()
// ... which somehow forces React to re-render
}Why it might be fine in your case:
| Your Architecture | Why Mutation Works |
|---|---|
Single run instance | Not passed between components as prop |
| Manual re-render trigger | You call _triggerUIUpdate() explicitly |
| Controller-driven updates | Controller knows when state changed |
| No parent-child prop passing | run isn't shared via props |
🔍 Let's Check YOUR Actual Case
Question 1: How is run created?
// Scenario A: run is a prop from parent
function MainForm({ run }) { // ❌ MUTATION IS PROBLEM
run.doc.state.events.push(event);
}
// Scenario B: run is local state
function MainForm() {
const [run, setRun] = useState(initialRun); // ❌ MUTATION IS PROBLEM
run.doc.state.events.push(event); // React won't see change
}
// Scenario C: run is external (singleton/global)
function MainForm() {
const run = coworker.currentRun; // ⚠️ DEPENDS
run.doc.state.events.push(event);
// If you force re-render somehow, might be OK
}
// Scenario D: run is ref
function MainForm() {
const runRef = useRef(initialRun); // ✅ MUTATION IS OK
runRef.current.doc.state.events.push(event);
forceUpdate(); // Manually trigger re-render
}🎯 Analysis: Is Mutation a Problem for YOU?
If run is passed as a prop:
<MainForm run={run} />❌ YES, mutation is a problem:
- React won't detect the change
- Component won't re-render automatically
- Parent component has stale reference
If run is stored in useState:
const [run, setRun] = useState(initialRun);❌ YES, mutation is a problem:
- You mutate but never call
setRun() - React doesn't know state changed
- Component won't re-render
If run is a singleton/global object:
const run = coworker.currentRun; // Lives outside React⚠️ MAYBE OK, but hacky:
- If you manually force re-render after mutation → works
- But you're fighting React's design
- Hard to debug
- Breaks DevTools
If run is a useRef:
const runRef = useRef(initialRun);✅ MUTATION IS FINE:
- Refs are meant for mutable values
- You control when to re-render
- Pattern explicitly supports mutation
🔍 What Does _triggerUIUpdate() Actually Do?
_triggerUIUpdate(run) {
console.log(`🔄 UI update triggered`);
// ❓ What happens here?
}If it does this:
// ❌ Does nothing - just logs
_triggerUIUpdate(run) {
console.log(`🔄 UI update triggered`);
}
// Then mutation IS a problem - React won't re-renderIf it does this:
// ✅ Forces re-render
_triggerUIUpdate(run) {
setForceUpdate(prev => prev + 1); // Trigger re-render
}
// Then mutation works, but is hackyIf it does this:
// ✅ Immutable update
_triggerUIUpdate(run) {
setRun({ ...run }); // New reference
}
// Then you're already fixing the mutation issue🎯 The Real Question: How Do You Re-render?
Looking at your MainForm:
const MainForm = ({ run }) => {
// ...
const handleButtonClick = (actionName) => {
run.doc.state.events.push({ /* event */ }); // Mutation
coworker.controller.handleEvent(run);
};
// ...
}Critical questions:
- Is
runa prop? → If yes, parent needs to know about mutation - Does
handleEvent()trigger re-render? → If no, UI won't update - How does MainForm know to re-render? → Without
setRun(), it won't
🎯 When Mutation is ACTUALLY Fine in Your Case
✅ Mutation is OK if ALL these are true:
runis stored in auseRef:
const runRef = useRef(initialRun);- You have explicit re-render mechanism:
const [, forceUpdate] = useReducer(x => x + 1, 0);
const handleButtonClick = (actionName) => {
runRef.current.doc.state.events.push(event);
controller.handleEvent(runRef.current);
forceUpdate(); // Explicit re-render
};runis never passed as prop to children:
// ✅ OK - run stays in parent
<div>{runRef.current.doc.name}</div>
// ❌ NOT OK - child won't see updates
<ChildComponent run={runRef.current} />🎯 Quick Test: Is Mutation a Problem for You?
Test 1: Does UI update after mutation?
// Try this:
run.doc.state.events.push(event);
console.log("Event pushed");
// Does MainForm re-render immediately?
// Can you see the change in UI?- YES, UI updates → Your mutation strategy works (but may be hacky)
- NO, UI doesn't update → Mutation IS a problem
Test 2: Check React DevTools
- Open React DevTools
- Click button
- Does component highlight (indicating re-render)?
- Does state show updated in DevTools?
Test 3: Add console.log in render
const MainForm = ({ run }) => {
console.log("MainForm rendering", run.doc.state.events.length);
// ...
}
// Click button
// Does "MainForm rendering" log again?🎯 My Verdict for YOUR Case
I need to know:
- How is
runcreated/passed to MainForm? - What does
_triggerUIUpdate()actually do? - Does MainForm re-render after button clicks?
If:
runis a prop from parent → ❌ Mutation IS a problemrunis inuseState→ ❌ Mutation IS a problemrunis inuseRef+ manual re-render → ✅ Mutation is OK (but non-standard)runis singleton + you force re-render → ⚠️ Works but hacky
💡 Pragmatic Answer
If your UI updates correctly after mutations:
- ✅ Technically it "works"
- ⚠️ But it's non-standard React
- ⚠️ Makes debugging harder
- ⚠️ React DevTools won't work properly
- ⚠️ Time-travel debugging impossible
The "issue" isn't that it doesn't work—it's that:
- It goes against React conventions
- It's harder to maintain
- It confuses other developers
- It breaks tooling
So the real question is:
- Does it work in your case? → Probably yes
- Is it a "best practice"? → No
- Should you fix it? → Depends on your goals
Tell me:
- How is
runpassed to MainForm? - Does UI update after mutations?
- Are you OK with non-standard patterns?
Then I can give you a concrete answer for YOUR specific case.
Ah! Now I understand completely. You're asking: "Why is mutation bad when I'm just queueing business events for the controller to process?"
This is a totally valid question because you're distinguishing between:
- Business events (for controller logic)
- UI state (for React rendering)
🎯 The Core Insight
// You're doing this:
run.doc.state.events.push(event); // Queue business event
controller.handleEvent(run); // Controller processes it
// React doesn't need to re-render yet!
// The controller will process the event and THEN update state
// THEN React should re-renderYour mental model is correct:
- Events are commands/messages for the controller
- They're not "UI state" that React needs to watch
- Controller processes them and updates actual state
- Then UI re-renders based on that state
✅ When Mutation is Actually FINE
Your case fits this pattern:
// 1. Queue event (mutation is fine here)
run.doc.state.events.push({
type: "workflow_action",
action: "submit"
});
// 2. Controller processes queue
controller.handleEvent(run);
// - Reads events from queue
// - Processes business logic
// - Updates run.doc.state.workflow, persistence, etc.
// - Clears events queue
// 3. UI re-renders based on updated state
// Now React sees: run.doc.state.workflow changed from "Draft" to "Submitted"This is like:
- Message Queue Pattern - events are messages
- Command Pattern - events are commands
- Event Bus Pattern - events array is the bus
🎯 Why This Pattern Works
The key is: events are transient, state is permanent
run.doc.state = {
// ✅ Permanent state (React should watch this)
workflow: "Draft",
persistence: "clean",
docstatus: 0,
validationErrors: {},
// ⚠️ Transient queue (just messages for controller)
events: [
{ type: "workflow_action", action: "submit" }
]
};After controller processes:
run.doc.state = {
// ✅ State changed - React re-renders
workflow: "Submitted", // Changed!
persistence: "saved", // Changed!
docstatus: 1, // Changed!
validationErrors: {},
// ⚠️ Queue cleared - controller consumed events
events: [] // Empty
};🔍 The Real Question: Does React Know State Changed?
Scenario A: React doesn't need to know about event queue
const MainForm = ({ run }) => {
const handleButtonClick = (actionName) => {
// 1. Queue event (mutation)
run.doc.state.events.push({ type: "workflow_action", action: actionName });
// 2. Process synchronously
controller.handleEvent(run);
// Controller updates run.doc.state.workflow, etc.
// 3. Force re-render
forceUpdate(); // or setRun({ ...run })
};
// Render based on permanent state
const uiState = controller.getUIState(run);
return <div>{uiState.workflow}</div>;
};✅ This works because:
- Events are just messages - UI doesn't render them
- Controller processes events and updates real state
- You trigger re-render after processing
- React sees workflow/persistence/etc changed
🎯 When Mutation IS Actually a Problem
Only if you try to RENDER the events:
// ❌ BAD: Trying to render events array
const MainForm = ({ run }) => {
return (
<div>
<h3>Pending Events: {run.doc.state.events.length}</h3>
{run.doc.state.events.map(e =>
<div key={e.timestamp}>{e.action}</div>
)}
</div>
);
};
// User clicks button
run.doc.state.events.push(event); // Mutation
// UI shows: "Pending Events: 0" (stale!)
// React didn't detect the pushBut you're NOT doing this! You're doing:
// ✅ GOOD: Rendering permanent state, not events
const MainForm = ({ run }) => {
const uiState = controller.getUIState(run);
return (
<div>
<h3>Status: {uiState.workflow}</h3>
<button>Submit</button>
</div>
);
};
// Events are invisible to UI
// UI only cares about workflow/persistence/etc🎯 The Pattern You're Using
This is actually a well-known pattern:
Command Queue Pattern
// Commands are queued (mutation OK)
commandQueue.push(command);
// Processor consumes queue
processor.process(commandQueue);
// Queue is cleared
commandQueue.length = 0;
// State is updated (immutably if needed)
state = processor.getState();Used in:
- Redux middleware (action queue)
- Event sourcing systems (event log)
- Message queues (RabbitMQ, etc.)
- Game loops (input queue)
✅ Your Pattern is Valid IF:
1. Events are write-only (never rendered)
// ✅ UI never shows events
run.doc.state.events.push(event); // OK
// ❌ UI tries to show events
<div>{run.doc.state.events.length}</div> // Won't update2. Controller processes events synchronously
// ✅ Process immediately
run.doc.state.events.push(event);
controller.handleEvent(run); // Processes now
// ⚠️ Process async
run.doc.state.events.push(event);
setTimeout(() => controller.handleEvent(run), 1000); // UI stale for 1s3. You trigger re-render after processing
// ✅ Force re-render after state change
controller.handleEvent(run);
forceUpdate();
// ❌ No re-render
controller.handleEvent(run);
// UI shows stale state4. Events are cleared after processing
// ✅ Controller clears queue
handleEvent(run) {
const events = [...run.doc.state.events];
run.doc.state.events = []; // Clear
events.forEach(e => this.process(e));
}
// ❌ Events accumulate forever
handleEvent(run) {
run.doc.state.events.forEach(e => this.process(e));
// Forgot to clear!
}🎯 Why Standard React Advice Doesn't Apply
Standard React advice:
"Never mutate state! Use setState/immutable updates!"
Why that advice exists:
- React uses reference equality to detect changes
- If you mutate, React won't re-render
- Your UI gets out of sync with state
But in your case:
- Events aren't "state" - they're commands
- UI doesn't render events
- Controller consumes and clears them
- Permanent state (workflow, persistence) is what matters
Analogy:
// Standard React state (should be immutable)
const [todos, setTodos] = useState([]);
todos.push(newTodo); // ❌ BAD - React won't see it
// Your event queue (mutation OK)
const eventQueue = [];
eventQueue.push(event); // ✅ OK - just a message bus
processQueue(eventQueue);🎯 The Only Real Issue
The ONLY issue with your pattern is:
// You're mixing command queue with permanent state
run.doc.state = {
workflow: "Draft", // ← Permanent state
persistence: "clean", // ← Permanent state
events: [...] // ← Transient queue
};Better separation:
// Permanent state (React watches this)
run.doc.state = {
workflow: "Draft",
persistence: "clean",
docstatus: 0
};
// Transient queue (controller consumes this)
run.doc._pendingEvents = [
{ type: "workflow_action", action: "submit" }
];Or even better:
// Controller owns the queue
class Controller {
constructor() {
this._eventQueue = []; // Private queue
}
queueEvent(event) {
this._eventQueue.push(event); // Mutation is fine
}
handleEvents(run) {
const events = [...this._eventQueue];
this._eventQueue = [];
events.forEach(e => this._process(run, e));
}
}
// Usage
controller.queueEvent({ type: "workflow_action", action: "submit" });
controller.handleEvents(run);✅ Final Answer
For YOUR case:
| Aspect | Is Mutation OK? | Why |
|---|---|---|
| Pushing to events array | ✅ YES | Events are transient commands, not rendered state |
| Events never rendered | ✅ YES | UI doesn't show events, so mutation doesn't matter |
| Controller processes immediately | ✅ YES | No async delay where UI would be stale |
| You trigger re-render after | ✅ YES | React sees updated workflow/persistence/etc |
| Events are cleared | ✅ YES | Queue doesn't grow unbounded |
Verdict: Mutation is FINE in your case ✅
The only improvement would be:
- Separate event queue from permanent state
- Move queue to controller instead of
run.doc.state
But current approach works because:
- Events are invisible to React
- Controller consumes them immediately
- Permanent state updates trigger re-renders
💡 Bottom Line
You're right. The standard "don't mutate" advice doesn't apply to your event queue because:
- Events aren't UI state - they're business commands
- UI never renders events - it renders workflow/persistence/etc
- Controller processes synchronously - no stale UI
- Events are transient - cleared after processing
This is a valid pattern. The only "issue" is aesthetic - mixing commands with state. But functionally, it works fine.
If you want to be more explicit:
// Instead of pushing to state
run.doc.state.events.push(event);
// Could push to separate queue
controller.queueEvent(run, event);But your current approach is functionally correct for your use case.
Ah! NOW I see the real issue! Let me trace through what actually happens with buttons:
🔍 The Button Re-render Problem
const MainForm = ({ run }) => {
// 1. Get UI state from controller
const uiState = coworker.controller.getUIState(run);
// 2. Render buttons based on uiState
uiState.buttons.map(button =>
React.createElement(
"button",
{
onClick: () => handleButtonClick(button.action),
disabled: button.disabled, // ← This can change!
},
button.label
)
)
};The Issue:
// Initial render
uiState = {
workflow: "Draft",
buttons: [
{ action: "save", label: "Save", disabled: false },
{ action: "submit", label: "Submit", disabled: false }
]
};
// User clicks Submit button
run.doc.state.events.push({ action: "submit" }); // ❌ Mutation
controller.handleEvent(run);
// Controller changes: workflow → "Submitted", persistence → "saving"
// Now buttons should change:
uiState = {
workflow: "Submitted",
buttons: [
{ action: "cancel", label: "Cancel", disabled: false } // Different buttons!
]
};
// ❓ But does React know to re-render?🎯 The Problem: React Doesn't Know run Changed
Your flow:
const MainForm = ({ run }) => { // ← run is a prop
const handleButtonClick = (actionName) => {
// 1. Mutate run
run.doc.state.events.push(event); // ❌ Mutation
// 2. Controller processes
controller.handleEvent(run);
// This mutates run.doc.state.workflow, run.doc.state.persistence, etc.
// 3. ❓ How does React know to re-render?
};
// This needs to re-run to get new buttons
const uiState = controller.getUIState(run); // ← Depends on run
return (
<div>
{uiState.buttons.map(btn => ...)} {/* ← Needs to show new buttons */}
</div>
);
};The problem:
runis mutated (not a new reference)- React sees
runreference unchanged - React doesn't re-render MainForm
getUIState(run)never gets called again- Buttons stay the same (stale)
🔍 Let Me Trace the ACTUAL Flow
Scenario: User clicks "Submit" button
// Step 1: User clicks Submit
handleButtonClick("submit")
// Step 2: Queue event
run.doc.state.events.push({
type: "workflow_action",
action: "submit",
timestamp: Date.now()
});
// Step 3: Controller processes
controller.handleEvent(run) {
// Read events
const events = [...run.doc.state.events]; // [{ action: "submit" }]
run.doc.state.events = []; // Clear
// Process submit action
_processWorkflowAction(run, event) {
// Update state (MUTATION!)
run.doc.state.workflow = "Submitted";
run.doc.state.docstatus = 1;
run.doc.state.persistence = "saving";
}
// Call _triggerUIUpdate
_triggerUIUpdate(run) {
console.log("🔄 UI update triggered");
// ❓ What happens here?
}
}
// Step 4: React needs to re-render
// BUT: run reference is same object
// React doesn't know it changed
// MainForm doesn't re-render
// Buttons stay as "Save" and "Submit" (stale!)🎯 The REAL Issue: No Re-render Trigger
Your _triggerUIUpdate() is probably just a log:
_triggerUIUpdate(run) {
console.log(`🔄 UI update triggered`);
// ❌ This doesn't actually trigger React to re-render!
}For React to re-render, you need ONE of these:
// Option 1: setState with new reference
const [run, setRun] = useState(initialRun);
_triggerUIUpdate(run) {
setRun({ ...run }); // New reference → React re-renders
}
// Option 2: Force update
const [, forceUpdate] = useReducer(x => x + 1, 0);
_triggerUIUpdate(run) {
forceUpdate(); // Force re-render
}
// Option 3: Observable/state management
const [revision, setRevision] = useState(0);
_triggerUIUpdate(run) {
setRevision(r => r + 1); // Change triggers re-render
}🎯 Why Buttons ARE the Issue
Buttons need to update based on workflow state:
// Draft state → Show Save + Submit
uiState.buttons = [
{ action: "save", label: "Save" },
{ action: "submit", label: "Submit" }
]
// After Submit → Show Cancel only
uiState.buttons = [
{ action: "cancel", label: "Cancel" }
]
// This requires React to:
// 1. Re-run getUIState(run)
// 2. Re-render button elements
// 3. Remove old buttons, show new buttonsBut if React doesn't re-render:
- Old buttons stay visible
- Clicking "Submit" again might process again (bad!)
- UI is out of sync with state
🔍 Test: Is This Your Actual Problem?
Add this to MainForm:
const MainForm = ({ run }) => {
console.log("🎨 MainForm rendering", {
workflow: run.doc.state.workflow,
docstatus: run.doc.state.docstatus,
eventCount: run.doc.state.events?.length || 0
});
const uiState = coworker.controller.getUIState(run);
console.log("🔘 Buttons:", uiState.buttons.map(b => b.action));
// ... rest of render
};
```
### **Then click Submit button:**
**Expected output:**
```
🎨 MainForm rendering { workflow: "Draft", docstatus: 0, eventCount: 0 }
🔘 Buttons: ["save", "submit"]
// User clicks Submit
📥 Processing 1 queued events
⚙️ Event: workflow_action submit
🔄 submit: docstatus 0 → 1
💾 Submitting...
✅ Submitted
🔄 UI update triggered
// Should see this:
🎨 MainForm rendering { workflow: "Submitted", docstatus: 1, eventCount: 0 }
🔘 Buttons: ["cancel"]If you DON'T see the second render:
- ❌ React is NOT re-rendering
- ❌ Buttons are stale
- ❌ Mutation IS your problem
✅ Solutions
Solution 1: useState with immutable update (Recommended)
const MainForm = () => {
const [run, setRun] = useState(initialRun);
const handleButtonClick = (actionName) => {
// Queue event (still mutation, but OK - will fix below)
run.doc.state.events.push({
type: "workflow_action",
action: actionName,
timestamp: Date.now()
});
// Process
controller.handleEvent(run);
// ✅ Trigger re-render with new reference
setRun({ ...run });
};
const uiState = controller.getUIState(run);
return (
<div>
{uiState.buttons.map(btn =>
<button
key={btn.action}
onClick={() => handleButtonClick(btn.action)}
disabled={btn.disabled}
>
{btn.label}
</button>
)}
</div>
);
};Solution 2: useReducer for force update
const MainForm = ({ run }) => {
const [, forceUpdate] = useReducer(x => x + 1, 0);
const handleButtonClick = (actionName) => {
run.doc.state.events.push({
type: "workflow_action",
action: actionName,
timestamp: Date.now()
});
controller.handleEvent(run);
// ✅ Force re-render
forceUpdate();
};
const uiState = controller.getUIState(run);
return (
<div>
{uiState.buttons.map(btn =>
<button
key={btn.action}
onClick={() => handleButtonClick(btn.action)}
disabled={btn.disabled}
>
{btn.label}
</button>
)}
</div>
);
};Solution 3: Controller returns new run (Cleanest)
// Controller
handleEvent(run) {
const state = this._initState(run);
const events = [...state.events];
state.events = [];
events.forEach(e => this._processEvent(run, e));
// ✅ Return new run reference
return { ...run };
}
// MainForm
const [run, setRun] = useState(initialRun);
const handleButtonClick = (actionName) => {
run.doc.state.events.push({
type: "workflow_action",
action: actionName,
timestamp: Date.now()
});
// ✅ Get new run and set state
const newRun = controller.handleEvent(run);
setRun(newRun);
};🎯 Why Buttons Prove Mutation IS a Problem
Your buttons MUST re-render because:
- Different workflow states show different buttons:
- Draft → Save, Submit
- Submitted → Cancel
- Cancelled → Amend
- Button states change:
disabled: false→disabled: true(while saving)
- This requires
getUIState(run)to run again - Which requires React to re-render MainForm
- Which requires React to know
runchanged - But mutation doesn't change the reference
✅ Final Answer
YES, mutation IS a problem for buttons because:
- ✅ You were right that event queue mutation is OK (transient commands)
- ❌ BUT controller also mutates
run.doc.state.workflow,persistence, etc. - ❌ These ARE rendered by UI (via
getUIState()) - ❌ React doesn't detect the mutation
- ❌ Buttons don't update
The fix:
// After controller processes
forceUpdate(); // or setRun({ ...run })Test it:
- Add console.log to see if MainForm re-renders after button click
- If it doesn't → mutation is the problem
- If it does → something else is triggering re-render (what?)
Can you show me your actual _triggerUIUpdate() implementation and how run is passed to MainForm?
Ah! Now I see it clearly!
🎯 How Your Badges Work (The Key Insight)
// Badge reads DIRECTLY from run.doc.docstatus
behavior.ui.badge
? React.createElement("span", { className: ... }, behavior.ui.badge.label)
: schema.is_submittable && doc.docstatus !== undefined
? (() => {
const badge = getDocstatusBadge(doc.docstatus); // ← Reads doc.docstatus
return badge ? React.createElement("span", ...) : null;
})()
: nullThe badge works because:
- It reads
doc.docstatusduring render docisrun.doc(reference)- When controller mutates
run.doc.docstatus = 1 - Badge reads the mutated value on next render
🎯 The Question: What Triggers the Re-render?
Your badges work, which means:
- ✅ MainForm IS re-rendering after button clicks
- ✅ Otherwise badge wouldn't update
So the question is: WHAT triggers that re-render?
Possibilities:
- Parent component triggers re-render (passes new
runprop) - Some state change in MainForm (useState/useReducer)
- handlers change (useMemo dependency triggers)
- schema change (useState triggers)
🔍 Let's Trace What Could Trigger Re-render
Looking at MainForm dependencies:
const handlers = React.useMemo(() => {
// ...
}, [run, behavior, profile]); // ← These dependencies!If run reference changes → handlers re-compute → component re-renders
But you said you mutate run, so reference shouldn't change... unless:
Hypothesis 1: behavior or profile cause re-render
const behavior = coworker.getBehavior(schema, doc);If getBehavior() returns a NEW object each time:
- Different reference
useMemodependency changes- Handlers re-compute
- Component re-renders
Test: Does getBehavior() create new object each call?
// If it does this:
getBehavior(schema, doc) {
return { // ← New object every time
controller: { autoSave: true },
ui: { badge: { class: "success", label: "Submitted" } }
};
}
// Then behavior !== previous behavior
// useMemo sees change
// Handlers re-compute
// Component re-renders🎯 Why Buttons Would Work with Same Pattern
If buttons are generated like badges:
// In controller.getUIState(run)
getUIState(run) {
const docstatus = run.doc.docstatus; // Read current value
const buttons = Object.entries(workflow.actions)
.filter(([name, config]) => config.from.includes(docstatus)) // ← Uses current docstatus
.map(([name, config]) => ({ ... }));
return { buttons };
}Then buttons work because:
- Controller mutates
run.doc.docstatus = 1 - Something triggers MainForm re-render (behavior? profile?)
- MainForm calls
getUIState(run)again getUIState()reads updatedrun.doc.docstatus- Returns different buttons
- Buttons update
🎯 The Real Architecture
You're not using React state for run at all!
const MainForm = ({ run }) => { // ← run is prop (mutable reference)
const [schema, setSchema] = React.useState(...); // ← Only schema in state
// NO: const [run, setRun] = useState(...)
// run is passed as prop and mutatedRe-renders are triggered by:
- ✅
schemastate changes - ✅
handlersuseMemo dependency changes - ✅ Parent component re-renders
- ❌ NOT by
runmutation directly
🎯 Why This Works
The Pattern:
// Parent (or somewhere) creates run
const run = { doc: { docstatus: 0 }, input: {} };
// Parent renders MainForm
<MainForm run={run} />
// User clicks button
// → Mutates run.doc.docstatus = 1
// → Calls controller which mutates more
// → Some side effect triggers re-render:
// - Parent re-renders
// - OR behavior/profile changes
// - OR schema changes
// MainForm re-renders
// Reads updated run.doc.docstatus
// Badge shows "Submitted"
// Buttons show "Cancel"🎯 The Missing Piece: What Triggers Re-render After Button Click?
I need to know:
- Where is
runcreated? (parent component?) - Does parent re-render after button clicks?
- Does
getBehavior()return new object each call? - Does
profilereference change?
If any of these change, it explains why badges/buttons work despite mutation.
💡 My Hypothesis
You're doing something like:
// Parent component
const ParentApp = () => {
const [revision, setRevision] = React.useState(0); // Force update trigger
const run = React.useRef({ doc: {}, input: {} }).current;
// Add observer or callback
coworker.controller.onStateChange = () => {
setRevision(r => r + 1); // Trigger re-render
};
return <MainForm run={run} key={revision} />;
};
// Controller
_triggerUIUpdate(run) {
if (this.onStateChange) {
this.onStateChange(); // Notify parent
}
}Or:
// getBehavior creates new object
getBehavior(schema, doc) {
return { // ← New reference each time
controller: { ... },
ui: { badge: this.getDocstatusBadge(doc.docstatus) }
};
}
// This causes useMemo to see change
// Which triggers re-render✅ Bottom Line
Your architecture works because:
- ✅ You mutate
run(reference stays same) - ✅ Something ELSE triggers re-render (behavior? parent? profile?)
- ✅ When re-render happens, components read updated
run.doc - ✅ Badges/buttons reflect current state
The mutation isn't the problem IF:
- Something reliably triggers re-renders after mutations
- That "something" is what I need to identify
Questions:
- What does
controller._triggerUIUpdate()actually do? - Does
getBehavior()create new object each call? - Where is
runcreated and managed? - Does parent component re-render?
Show me those pieces and I'll understand your complete architecture without overcomplicating.
Ah! Excellent question! Let me compare:
🎯 The Key Difference
Input Field (Updates Frequently):
// User types: "A" → "Ac" → "Acm" → "Acme"
onChange handler called 4 times
// Each onChange:
run.input[fieldname] = value; // Mutation
// ❓ Does UI need to re-render to show "Acme"?Button (Updates Workflow State):
// User clicks "Submit" once
onClick handler called 1 time
// onClick:
run.doc.docstatus = 1; // Mutation
// ❓ Does UI need to re-render to show different buttons?🔍 Input Field: Why Mutation Works
// renderField creates input
React.createElement(
"input",
{
value: safeValue, // ← Reads from doc initially
onChange: (e) => {
run.input[fieldname] = e.target.value; // Mutation
// ❓ Does input show new value?
}
}
)The Answer: Input is CONTROLLED by React state!
const [state, setState] = React.useState(() => {
const initialState = {};
for (const key in stateConfig) {
initialState[key] = this.evalTemplate(stateConfig[key], evalContext);
}
return initialState; // ← LOCAL state for this field
});
// Event handler updates LOCAL state
eventHandlers[eventName] = (e) => {
let newValue = e.target.value;
if (eventConfig.updateState) {
setState((prev) => ({
...prev,
[eventConfig.updateState]: newValue // ← Updates LOCAL state
}));
}
if (eventConfig.delegate && handlers[eventConfig.delegate]) {
handlers[eventConfig.delegate](field.fieldname, newValue); // ← ALSO mutates run.input
}
};
// Input uses LOCAL state
if (state.localValue !== undefined) {
elementProps.value = state.localValue; // ← Reads from LOCAL state, NOT run.doc
}
```
---
## 🎯 The Pattern: Input Fields Have LOCAL State
### **Input Field Flow:**
```
User types "A"
↓
onChange event
↓
setState({ localValue: "A" }) // ← LOCAL React state
↓
React re-renders THIS input (automatic)
↓
Input shows "A"
↓
ALSO: run.input[fieldname] = "A" // ← Side effect (mutation)
```
**Key:** Input re-renders because of `setState()`, NOT because `run` changed!
---
## 🎯 Button Flow (NO Local State)
### **Button Click Flow:**
```
User clicks "Submit"
↓
onClick event
↓
run.doc.state.events.push({ action: "submit" }) // Mutation
↓
controller.handleEvent(run)
↓
run.doc.docstatus = 1 // Mutation
↓
❓ What triggers re-render?
↓
❓ How do NEW buttons appear?Key: Buttons have NO local state, so MainForm must re-render to show different buttons!
🔍 Let's Check Your Field Config
Input field probably has:
fieldTypes: {
"Data": {
element: "input",
state: {
localValue: "{{value}}" // ← Creates LOCAL state
},
events: {
onChange: {
extract: "value",
updateState: "localValue", // ← Updates LOCAL state
delegate: "onChange" // ← ALSO delegates to run.input
}
}
}
}Button probably has:
fieldTypes: {
"Button": {
element: "button",
state: {}, // ← NO local state
events: {
onClick: {
custom: true,
handler: (e, setState, handlers, field) => {
// ❓ What happens here?
}
}
}
}
}🎯 The Difference
| Aspect | Input Field | Button |
|---|---|---|
| Has local state | ✅ YES (localValue) | ❌ NO |
| Updates via setState | ✅ YES | ❌ NO |
| Re-renders automatically | ✅ YES (setState triggers) | ❌ NO |
| Mutates run | ✅ YES (side effect) | ✅ YES (primary effect) |
| Needs MainForm re-render | ❌ NO (has own state) | ✅ YES (to show new buttons) |
🎯 Why Input Works Without MainForm Re-render
// Initial render
<input value="Acme Corp" /> // From doc
// Component has state: { localValue: "Acme Corp" }
// User types → "Acme Corporation"
onChange fires
→ setState({ localValue: "Acme Corporation" }) // ← LOCAL state update
→ Input re-renders (automatic from setState)
→ run.input["customer_name"] = "Acme Corporation" // ← Side effect
// MainForm does NOT need to re-render!
// Input manages its own state🎯 Why Button NEEDS MainForm Re-render
// Initial render (docstatus = 0)
getUIState(run) returns:
buttons: [
{ action: "save", label: "Save" },
{ action: "submit", label: "Submit" }
]
MainForm renders:
<button>Save</button>
<button>Submit</button>
// User clicks "Submit"
onClick fires
→ run.doc.docstatus = 1 // ← Mutation
→ ❓ How do buttons update?
// Buttons have NO local state
// They're rendered from uiState.buttons
// Which comes from getUIState(run)
// Which reads run.doc.docstatus
// For buttons to update:
MainForm MUST re-render
→ Calls getUIState(run) again
→ Reads run.doc.docstatus (now 1)
→ Returns different buttons:
buttons: [{ action: "cancel", label: "Cancel" }]
→ Renders different buttons🎯 The Core Insight
Input fields are "local":
- Each input manages its own display state
setState()triggers re-render of THAT input- Mutation of
run.inputis just a side effect - MainForm doesn't need to re-render
Buttons are "global":
- Buttons are rendered from controller state
- Controller state lives in
run.doc - Button list changes based on workflow state
- MainForm MUST re-render to show different buttons
🎯 So What Triggers MainForm Re-render?
Looking at your code, the only things that trigger re-render are:
schemastate changes:
const [schema, setSchema] = React.useState(...);handlersdependency changes:
const handlers = React.useMemo(() => {...}, [run, behavior, profile]);If behavior or profile are NEW objects each render, this triggers
- Parent component re-renders
Since badges work, one of these MUST be triggering after button clicks.
💡 The Question
Input fields work because they have LOCAL state (independent re-renders).
Buttons need GLOBAL re-render (MainForm re-renders).
So: What triggers that global re-render in your case?
Is it:
- A)
getBehavior()returns new object →behaviorchanges →handlersre-compute → re-render? - B) Parent component triggers re-render?
- C) Something in
controller._triggerUIUpdate()? - D) Something else?
That's the missing piece!
EXCELLENT question! Why can't buttons have local state like inputs do?
🎯 The Problem with Button Local State
Input with Local State (Works):
// Each input is independent
<input value={localState.value} /> // Shows "Acme Corp"
<input value={localState.value} /> // Shows "100"
<input value={localState.value} /> // Shows "2024-01-01"
// Each input manages its own display
// They don't affect each otherButtons with Local State (Doesn't Work):
// Buttons are NOT independent - they're a SET
// Draft state:
<button>Save</button>
<button>Submit</button>
// After Submit - DIFFERENT buttons:
<button>Cancel</button>
// The whole SET changes, not individual buttons🎯 The Core Difference
Input Fields:
- One field = one value
- Field manages its own state
- Other fields don't care what this field does
- Local state works perfectly
Buttons:
- Button set = workflow state
- Buttons are derived from workflow
- Which buttons show depends on global state (docstatus)
- One button affects which other buttons exist
🔍 Let's Try Button Local State
Attempt 1: Each button has local state
// Draft state - render 2 buttons
<Button action="save" label="Save" /> // Has state: { visible: true }
<Button action="submit" label="Submit" /> // Has state: { visible: true }
// User clicks Submit
// Submit button's onClick:
setState({ visible: false }); // Hide self
// But now we need:
// - Save button to hide (how does it know?)
// - Cancel button to appear (doesn't exist yet!)Problem: Each button only knows about itself, but workflow state affects ALL buttons.
🎯 Fundamental Issue: Buttons Are Collective, Not Individual
Input analogy that WOULD need global state:
Imagine inputs that behave like buttons:
// When "country" = "USA", show these fields:
<input name="state" />
<input name="zip_code" />
// When "country" = "Canada", show DIFFERENT fields:
<input name="province" />
<input name="postal_code" />
// The EXISTENCE of fields depends on GLOBAL state (country)
// Each field can't manage this with local stateThat's exactly what buttons are!
🎯 Why Button Visibility is Different from Input Value
Input Value (Local):
// Input controls its own display
<input value={localValue} />
// When user types:
setState({ localValue: newValue })
// Input re-renders itself
// Done!Button Visibility (Global):
// Buttons rendered based on workflow
{buttons.map(btn => <button>{btn.label}</button>)}
// When user clicks Submit:
// - "Save" button must disappear
// - "Submit" button must disappear
// - "Cancel" button must appear (didn't exist before!)
// This requires:
// 1. Workflow state to update (docstatus: 0 → 1)
// 2. Re-compute which buttons should exist
// 3. Re-render button container with new set
// Can't do this with local state in each button🎯 Could We Make Buttons Work with Local State?
Attempt: Button manages its own visibility
const Button = ({ action, run }) => {
// Read workflow state
const workflow = getWorkflow(run.target.schema);
const currentDocstatus = run.doc.docstatus;
// Check if this button should show
const actionConfig = workflow.actions[action];
const shouldShow = actionConfig.from.includes(currentDocstatus);
// Local state for visibility
const [visible, setVisible] = React.useState(shouldShow);
if (!visible) return null;
return (
<button onClick={() => {
// Update workflow
run.doc.docstatus = actionConfig.to;
// Hide self
setVisible(false);
// ❌ But how do OTHER buttons know to update?
}}>
{actionConfig.label}
</button>
);
};Problem:
- Save button shows/hides based on
run.doc.docstatus - Submit button shows/hides based on
run.doc.docstatus - They read the SAME state
- When one button updates docstatus, OTHER buttons need to re-compute
shouldShow - But they won't, because they're using
useState(shouldShow)- initial value only!
🎯 The Real Issue: Initial State vs Reactive State
Input works with useState:
// Input reads its own data
const [localValue, setLocalValue] = useState(doc[fieldname]);
// User types
setLocalValue(newValue); // Updates local state
// Input re-renders with new value
// Perfect!Button with useState (Doesn't Work):
// Button reads shared workflow state
const shouldShow = actionConfig.from.includes(run.doc.docstatus);
const [visible, setVisible] = useState(shouldShow); // ← INITIAL value only!
// Another button updates docstatus
run.doc.docstatus = 1;
// This button still shows old visibility
// Because useState only runs once
// visible is still based on OLD docstatus🎯 To Make Buttons Work with Local State, You'd Need:
Option 1: useEffect to watch docstatus
const Button = ({ action, run }) => {
const [visible, setVisible] = useState(true);
React.useEffect(() => {
const shouldShow = checkIfShouldShow(action, run.doc.docstatus);
setVisible(shouldShow);
}, [run.doc.docstatus]); // ← Watch docstatus
// ❌ But run.doc.docstatus is mutated,AH! Perfect example! Now I see the pattern clearly.
🎯 Why Link Field Works with Local State
Link: {
customComponent: true,
render: function ({ field, value, handlers, run }) {
const [options, setOptions] = React.useState([]); // ← Local state
const [isOpen, setIsOpen] = React.useState(false); // ← Local state
const [searchText, setSearchText] = React.useState(value || ""); // ← Local state
// Component manages its own display
// Clicking option updates its own searchText
// Opening dropdown updates its own isOpen
// Loading options updates its own optionsThis works because:
- Link field manages its own UI state (dropdown open/closed, search text, options)
- Clicking option doesn't affect OTHER link fields
- One link field doesn't care what another link field is doing
- Self-contained component
🎯 Why Buttons CAN'T Use Same Pattern
Link Field (Independent):
// Customer field
<Link value="Acme Corp" options={[...]} isOpen={false} />
// Supplier field
<Link value="Supplier Inc" options={[...]} isOpen={false} />
// Opening Customer dropdown doesn't affect Supplier dropdown
// Each manages its own state independentlyButtons (Interdependent):
// Draft state
<Button action="save" visible={true} />
<Button action="submit" visible={true} />
// Click Submit
// → Docstatus changes: 0 → 1
// → BOTH buttons must hide
// → NEW button must appear:
<Button action="cancel" visible={true} />
// One button's action affects ALL buttons
// They share workflow state🎯 The Key Difference
Link Field State is LOCAL:
| State | Scope | Affects |
|---|---|---|
searchText | This field only | This field's input |
isOpen | This field only | This field's dropdown |
options | This field only | This field's dropdown list |
Independence: Changing one Link field doesn't affect other Link fields
Button State is GLOBAL:
| State | Scope | Affects |
|---|---|---|
docstatus | Document-wide | ALL buttons |
workflow | Document-wide | Which buttons exist |
persistence | Document-wide | Button disabled state |
Interdependence: Clicking one button changes which buttons exist
🎯 Could Buttons Work Like Link Fields?
Let's Try:
Button: {
customComponent: true,
render: function ({ field, run }) {
const [visible, setVisible] = React.useState(true); // ← Local state
const handleClick = () => {
// Update workflow
run.doc.docstatus = 1;
// Hide self
setVisible(false);
// ❌ Problem: OTHER buttons still visible!
// They don't know docstatus changed
};
if (!visible) return null;
return React.createElement("button", { onClick: handleClick }, field.label);
}
}The problem:
// Render buttons in MainForm
fields.map(field => {
if (field.fieldtype === "Button") {
return renderButton({ field, run }); // ← Each button rendered independently
}
})
// Each button has its own local state
// They can't communicate with each other
```
---
## 🎯 The Fundamental Difference
### **Link Field:**
```
Component owns its display state
↓
User interaction updates component state
↓
Component re-renders itself
↓
Done! (self-contained)
```
### **Buttons:**
```
Buttons display based on shared workflow state
↓
User clicks button → updates shared state
↓
ALL buttons need to check: "Should I still exist?"
↓
Requires parent to re-render entire button set
↓
NOT self-contained🎯 Another Way to Think About It
Link Field is like a Light Switch:
- Each switch controls its own light
- Turning on Kitchen light doesn't affect Bedroom light
- Independent components
Buttons are like Traffic Lights:
- When one light turns green, others turn red
- The state of one affects the state of others
- Coordinated system
🎯 Could We Make Buttons Self-Contained?
Only if each button could observe workflow state:
Button: {
customComponent: true,
render: function ({ field, run }) {
// ❌ This won't work with mutation
const [shouldShow, setShouldShow] = React.useState(
checkIfVisible(field.action, run.doc.docstatus)
);
// ✅ Would need to watch docstatus
React.useEffect(() => {
const newVisibility = checkIfVisible(field.action, run.doc.docstatus);
setShouldShow(newVisibility);
}, [run.doc.docstatus]); // ← But mutation doesn't trigger this!
// This never re-runs because run.doc.docstatus is mutated, not changed
}
}The issue: useEffect depends on run.doc.docstatus reference, but you mutate it, so dependency doesn't change.
🎯 Why Link Field Pattern Works
Link field doesn't depend on external state changes:
// Link field state flow:
User focuses input
→ loadOptions()
→ setOptions([...]) // ← setState
→ Component re-renders
→ Shows dropdown
User clicks option
→ handleSelect()
→ setSearchText(value) // ← setState
→ setIsOpen(false) // ← setState
→ Component re-renders
→ Shows selected value, hides dropdown
// Everything driven by setState
// No dependency on external mutationsButton would need:
// Button state flow:
User clicks Submit
→ run.doc.docstatus = 1 // ← Mutation
→ ❓ How does Save button know to check visibility?
→ ❓ How does Cancel button know to appear?
→ ❓ How do components observe this change?
// Would need external observer/event system
// OR parent re-render💡 The Answer to Your Question
"Why can't button behave like input/Link field?"
Because:
- Link field manages isolated state (dropdown open, search text, options)
- Changes don't affect other fields
- Self-contained component
- Buttons manage shared state (workflow, docstatus)
- One button's action affects ALL buttons
- Which buttons exist depends on global state
- Not self-contained
- Link field uses setState (triggers its own re-render)
- Full control over its own display
- Buttons read external state (run.doc.docstatus)
- When external state changes via mutation
- No automatic re-render trigger
- Need parent to re-render and re-compute button set
🎯 Summary
| Aspect | Link Field | Buttons |
|---|---|---|
| State scope | Local (this field only) | Global (all buttons) |
| Independence | Standalone | Interdependent |
| State updates | setState() | Mutation of run.doc |
| Re-render trigger | Automatic (setState) | External (parent) |
| Pattern | Self-contained component | Derived from global state |
Bottom line: Link field is like an independent component. Buttons are like a computed view of workflow state. That's why they need different patterns.
You're right. Let me simplify.
The Simple Answer
Link field:
- Shows dropdown based on
isOpen(local state) - User clicks →
setIsOpen(true)→ dropdown appears - Component re-renders itself
Button:
- Shows based on
docstatus(inrun.doc) - User clicks →
run.doc.docstatus = 1(mutation) - ❓ What makes component re-render to show new buttons?
The Question
If buttons used local state like Link does:
Button: {
render: ({ field, run }) => {
const [label, setLabel] = useState("Submit");
return <button onClick={() => {
run.doc.docstatus = 1;
setLabel("Cancel"); // This button updates itself
}}>{label}</button>
}
}Would this work?
Your turn: What happens to the OTHER buttons (Save button, etc.) when Submit is clicked?
YES! Exactly right!
✅ The Correct View
Button is a FUNCTION of STATE
// State → Buttons
docstatus = 0 → [Save, Submit]
docstatus = 1 → [Cancel]
docstatus = 2 → [Amend]It's a pure function:
getButtons(docstatus) → buttons[]So the question becomes:
Link field:
- Has state:
[isOpen, setIsOpen] - User clicks →
setIsOpen(true)→ re-renders - Shows dropdown
Buttons:
- Are function of:
docstatus - User clicks →
docstatus = 1(mutation) - ❓ What triggers re-computation of
getButtons(docstatus)?
The real question:
How does the component know to re-run getButtons() after docstatus mutates?
YES! Exactly!
Button: {
render: ({ field, run }) => {
return <button onClick={() => {
// Mutate state
run.doc.docstatus = 1;
// ✅ Trigger re-render here!
// What do you call?
}}>
{field.label}
</button>
}
}So in onClick, after you mutate run.doc.docstatus, what triggers the re-render?
That's what I'm trying to understand about your architecture.
Is it:
controller.handleEvent(run)that somehow triggers re-render?- Something else?
Perfect! So the flow is:
Button onClick:
1. Mutate: run.doc.docstatus = 1
2. Call: controller.handleEvent(run)
3. Controller does: ???
4. Result: Component re-renders
5. getButtons(run.doc.docstatus) runs again
6. New buttons appearSo my question is: What does controller.handleEvent(run) do in step 3 that causes step 4 (re-render)?
Does it:
- Return something that MainForm uses?
- Call a callback?
- Trigger an event?
- Something else?
That's the piece I need to understand.
AH! YES!
The Pattern
Link field:
handleSelect = (option) => {
setSearchText(option.name); // ← Local state update (re-renders Link)
handlers.onChange(field.fieldname, option.name); // ← Mutates run.input
handlers.onBlur(field.fieldname, option.name); // ← Calls controller
}Button:
handleClick = (action) => {
// No local state to update
controller.handleEvent(run, action); // ← Calls controller
}So the question is:
Link field:
handlers.onBlur()calls controller- But Link re-renders from
setSearchText(), NOT from controller
Button:
controller.handleEvent()calls controller- Button re-renders from... what?
Does controller.handleEvent(run) somehow trigger MainForm to re-render?
Or is there something else that causes buttons to update?
Good question!
Looking at Link field:
const handleSelect = (option) => {
setSearchText(option.name); // ← setState (re-renders THIS component)
setIsOpen(false); // ← setState (re-renders THIS component)
handlers.onChange(field.fieldname, option.name); // ← Mutates run.input
handlers.onBlur(field.fieldname, option.name); // ← Calls controller
}Link re-renders because of setState() - it has local state.
But also - does MainForm re-render after handlers.onBlur()?
Because if MainForm re-renders, then:
- Link field would re-render too (as child)
- Buttons would re-render (showing new buttons)
So the real question:
Does handlers.onBlur() (which calls controller) trigger MainForm to re-render?
If yes → that's how buttons update too If no → buttons can't update (unless they have local state like Link)
Exactly! So why CAN'T buttons have local state?
Let me think through this with you:
If Button had local state like Link:
Button: {
render: ({ field, run }) => {
const [label, setLabel] = useState(field.label);
return <button onClick={() => {
controller.handleEvent(run);
setLabel("New Label"); // ← Update local state
}}>
{label}
</button>
}
}This would work for updating ONE button.
But the problem is:
// Draft state - MainForm renders:
<Button field={{ action: "save" }} /> // Shows "Save"
<Button field={{ action: "submit" }} /> // Shows "Submit"
// User clicks Submit
// Submit button's onClick runs:
controller.handleEvent(run); // docstatus = 0 → 1
// Now we need:
// - Save button to disappear
// - Submit button to disappear
// - Cancel button to appear (doesn't exist yet!)With local state:
- Submit button can hide itself with
setVisible(false) - But Save button doesn't know to hide (it has its own state)
- Cancel button doesn't exist yet (need to create it)
Is the issue that buttons need to be a COLLECTION, not individual components?
The button set needs to regenerate as a whole based on docstatus?
Wait - are you saying there's only ONE button element that changes its label/action?
Like this:
// Draft state - ONE button:
<button onClick={handleSave}>Save</button>
// After some action - SAME button, different handler:
<button onClick={handleSubmit}>Submit</button>
// After submit - SAME button, different handler:
<button onClick={handleCancel}>Cancel</button>Or do you have MULTIPLE buttons at once?
// Draft state - TWO buttons:
<button onClick={handleSave}>Save</button>
<button onClick={handleSubmit}>Submit</button>
// After submit - ONE button:
<button onClick={handleCancel}>Cancel</button>Which one is it in your system?
YES! Exactly!
The challenge is rendering the SET of buttons:
// Draft state:
<button onClick={handleSave}>Save</button>
<button onClick={handleSubmit}>Submit</button>
// After submit:
<button onClick={handleCancel}>Cancel</button>The SET changes - different number of buttons, different actions.
So the question is:
How does the component know to re-render the new SET after controller.handleEvent(run) mutates docstatus?
That's what triggers the re-computation of which buttons should exist, right?
YES! Exactly!
// In MainForm:
const buttons = getButtons(run.doc.docstatus); // Returns array based on state
buttons.map(btn =>
<button onClick={() => handleClick(btn.action)}>
{btn.label}
</button>
)So when controller.handleEvent(run) mutates run.doc.docstatus:
- Something triggers MainForm to re-render
getButtons(run.doc.docstatus)runs again- Returns different array of buttons
- New buttons render
The question is step 1: What triggers MainForm to re-render after the mutation?
That's what I'm trying to understand about your architecture.
Button: {
customComponent: true,
render: function ({ field, value, handlers, run }) {
// Determine which action to show based on docstatus
const getButtonConfig = (docstatus) => {
if (docstatus === 0) return { action: "submit", label: "Submit" };
if (docstatus === 1) return { action: "cancel", label: "Cancel" };
if (docstatus === 2) return { action: "amend", label: "Amend" };
return null;
};
const config = getButtonConfig(run.doc.docstatus);
if (!config) return null;
const handleClick = () => {
// Queue event
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: config.action,
timestamp: Date.now()
});
// Process
controller.handleEvent(run);
};
return React.createElement("button", {
onClick: handleClick,
className: window.CWStyles.button.primary
}, config.label);
}
}With 1 button substituting, there's NO issue!
Button renders → reads run.doc.docstatus → shows "Submit"
User clicks → mutates run.doc.docstatus = 1 → controller.handleEvent(run)
Button re-renders → reads run.doc.docstatus → shows "Cancel"The only question is: what triggers that re-render in step 3?
Does controller.handleEvent(run) return something? Call something? Trigger MainForm re-render?
That's the missing piece.
AH! That's the key insight!
// MainForm doesn't re-render
// Each field re-renders itself
Button: {
render: ({ run }) => {
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const config = getButtonConfig(run.doc.docstatus);
const handleClick = () => {
controller.handleEvent(run);
forceUpdate(); // ← Re-render THIS button only
};
return <button onClick={handleClick}>{config.label}</button>
}
}So Button CAN have local state - just needs forceUpdate() after mutation!
Is that the pattern?
You're right - let me look at YOUR actual code.
Looking at your renderField:
coworker.renderField = function ({ field, value, handlers, run }) {
// ...
// Create state
const [state, setState] = React.useState(() => {
// Initialize state from fieldType.state config
});
// Create event handlers
const eventHandlers = {};
for (const eventName in fieldType.events || {}) {
eventHandlers[eventName] = (e) => {
// ...
if (eventConfig.updateState) {
setState((prev) => ({ ...prev, [eventConfig.updateState]: newValue }));
}
if (eventConfig.delegate && handlers[eventConfig.delegate]) {
handlers[eventConfig.delegate](field.fieldname, newValue);
}
};
}
return React.createElement(fieldType.element, { ...elementProps, ...eventHandlers }, children);
};So every field CAN have local state via fieldType.state config.
For Button, you just need to configure it with state and events that trigger setState(), right?
Is the issue that Button config doesn't have state or updateState defined?
Yes, exactly!
In your schema, you'd have:
{
fieldname: "submit_button",
fieldtype: "Button",
label: "Submit"
}But wait - this creates a STATIC button that always shows "Submit".
For workflow buttons that change based on docstatus, you need the button's label/action to be dynamic based on run.doc.docstatus.
Should the button config read from run to determine what to show?
Like:
Button: {
layoutOnly: true,
render: function ({ field, handlers, run }) {
// Determine button based on docstatus
const getButtonConfig = (docstatus) => {
if (docstatus === 0) return { action: "submit", label: "Submit" };
if (docstatus === 1) return { action: "cancel", label: "Cancel" };
if (docstatus === 2) return { action: "amend", label: "Amend" };
return { action: field.fieldname, label: field.label };
};
const config = getButtonConfig(run.doc.docstatus);
const handleClick = () => {
if (handlers.onAction) {
handlers.onAction(config.action, config);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
}, config.label);
},
}Is that what you want?
The error is: handlers.onAction is undefined.
Looking at your MainForm code, you only define:
const handlers = {
onChange: (fieldname, value) => { ... },
onBlur: (fieldname, value) => { ... }
};You need to add onAction handler:
const handlers = React.useMemo(() => {
const executeAction = (action, fieldname, value) => {
// ... existing code
};
return {
onChange: (fieldname, value) => { ... },
onBlur: (fieldname, value) => { ... },
// Add this:
onAction: (actionName, data) => {
console.log(`🔘 Button action: ${actionName}`, data);
// Queue event
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: actionName,
timestamp: Date.now()
});
// Process via controller
if (coworker.controller?.handleEvent) {
coworker.controller.handleEvent(run);
}
}
};
}, [run, behavior, profile]);No, you're right - it's hardcoded in MainForm.
To make it config-based, you'd add button handling to your interaction profile:
fieldInteractionConfig: {
profiles: {
standard: {
onChange: { enabled: true, action: "write_draft", debounce: 300 },
onBlur: { enabled: true, action: "validate", debounce: 0 },
// Add this:
onClick: {
enabled: true,
action: "workflow_action", // or "custom_action"
debounce: 0
}
}
}
}Then in MainForm handlers, you'd read from profile like onChange/onBlur do:
onAction: (actionName, data) => {
const config = profile.onClick;
if (!config.enabled) return;
const perform = () => {
// Execute action based on config.action
// ...
};
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onClick_${actionName}`]);
timersRef.current[`onClick_${actionName}`] = setTimeout(perform, config.debounce);
} else {
perform();
}
}Is that what you want?
You're right - it should be onClick to be consistent!
Button should use the same event name as the profile config:
Button: {
layoutOnly: true,
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers.onClick) { // ← Same as profile event name
handlers.onClick(field.fieldname, field.label);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label || "Button");
},
}And in MainForm handlers:
onClick: (fieldname, value) => {
const config = profile.onClick;
if (!config.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onClick_${fieldname}`]);
timersRef.current[`onClick_${fieldname}`] = setTimeout(perform, config.debounce);
} else {
perform();
}
}Same pattern as onChange and onBlur!
You're absolutely right!
The handlers in MainForm should be generic - just read from profile and execute whatever action is configured.
const handlers = React.useMemo(() => {
// ✅ Generic executor - works for ANY event type
const handleEvent = (eventType, fieldname, value) => {
const config = profile[eventType];
if (!config?.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`${eventType}_${fieldname}`]);
timersRef.current[`${eventType}_${fieldname}`] = setTimeout(perform, config.debounce);
} else {
perform();
}
};
return {
onChange: (fieldname, value) => handleEvent("onChange", fieldname, value),
onBlur: (fieldname, value) => handleEvent("onBlur", fieldname, value),
onClick: (fieldname, value) => handleEvent("onClick", fieldname, value),
};
}, [run, behavior, profile]);Now onClick behavior is defined in profile config, not hardcoded in MainForm!
You just need to add onClick to your profile:
onClick: { enabled: true, action: "workflow_action", debounce: 0 }Exactly right!
onClick would apply to ANY field type that has an onClick event - buttons, links, custom components, etc.
If you want button-specific behavior, you need a button-specific event name:
// In profile config:
onButtonClick: { enabled: true, action: "workflow_action", debounce: 0 }
// In Button component:
handlers.onButtonClick(field.fieldname, field.label)
// In MainForm handlers:
onButtonClick: (fieldname, value) => handleEvent("onButtonClick", fieldname, value)OR keep onClick generic, but the action determines what happens:
// Profile config:
onClick: { enabled: true, action: "workflow_action", debounce: 0 }
// executeAction handles "workflow_action" differently:
executeAction(action, fieldname, value) {
switch (action) {
case "write_draft":
run.input[fieldname] = value;
break;
case "workflow_action":
// Button-specific logic
run.doc.state.events.push({ action: fieldname });
controller.handleEvent(run);
break;
}
}Which approach do you prefer?
No, onButtonClick is NOT a React event - it's a custom name you create.
React only has standard DOM events like:
onClickonChangeonBluronFocus- etc.
onButtonClick would be your custom handler name in the handlers object you pass to fields.
So the question is:
Do you want:
- Generic
onClick- any field can use it (button, div, span, etc.) - Custom
onButtonClick- only for buttons, separate from generic clicks
Both work, just different levels of specificity.
Perfect! So here's the implementation:
1️⃣ Add to Profile Config
fieldInteractionConfig: {
profiles: {
standard: {
onChange: { enabled: true, action: "write_draft", debounce: 300 },
onBlur: { enabled: true, action: "validate", debounce: 0 },
onButtonClick: { enabled: true, action: "workflow_action", debounce: 0 }
}
}
}2️⃣ Add Handler in MainForm
const handlers = React.useMemo(() => {
const executeAction = (action, fieldname, value) => {
switch (action) {
case "write_draft":
run.input[fieldname] = value;
console.log(`✅ Draft: ${fieldname} = ${value}`);
break;
case "validate":
run.input[fieldname] = value;
if (coworker.controller.validate) {
const validation = coworker.controller.validate(run);
if (validation && !validation.valid) {
run._validationErrors = validation.errors;
}
}
console.log(`✅ Validated: ${fieldname}`);
break;
case "workflow_action":
// Button action - queue event and process
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: fieldname,
timestamp: Date.now()
});
if (coworker.controller?.handleEvent) {
coworker.controller.handleEvent(run);
}
console.log(`✅ Workflow action: ${fieldname}`);
break;
default:
console.warn(`Unknown action: ${action}`);
}
};
return {
onChange: (fieldname, value) => {
const config = profile.onChange;
if (!config.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onChange_${fieldname}`]);
timersRef.current[`onChange_${fieldname}`] = setTimeout(perform, config.debounce);
} else {
perform();
}
},
onBlur: (fieldname, value) => {
const config = profile.onBlur;
if (!config.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onBlur_${fieldname}`]);
timersRef.current[`onBlur_${fieldname}`] = setTimeout(perform, config.debounce);
} else {
perform();
}
},
onButtonClick: (fieldname, value) => {
const config = profile.onButtonClick;
if (!config.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onButtonClick_${fieldname}`]);
timersRef.current[`onButtonClick_${fieldname}`] = setTimeout(perform, config.debounce);
} else {
perform();
}
},
};
}, [run, behavior, profile]);3️⃣ Update Button Config
Button: {
layoutOnly: true,
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers.onButtonClick) {
handlers.onButtonClick(field.fieldname, field.label);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label || "Button");
},
}Now buttons use onButtonClick which is completely separate from generic onClick events!
Here's the updated MainForm with onButtonClick handler added:
const handlers = React.useMemo(() => {
const executeAction = (action, fieldname, value) => {
switch (action) {
case "write_draft":
run.input[fieldname] = value;
console.log(`✅ Draft: ${fieldname} = ${value}`);
break;
case "validate":
run.input[fieldname] = value;
if (coworker.controller.validate) {
const validation = coworker.controller.validate(run);
if (validation && !validation.valid) {
run._validationErrors = validation.errors;
}
}
console.log(`✅ Validated: ${fieldname}`);
break;
case "auto_save":
run.input[fieldname] = value;
if (behavior.controller.autoSave) {
coworker.controller.autoSave(run);
console.log(`✅ Auto-saved: ${fieldname}`);
} else {
console.log(`⚠️ Auto-save disabled by behavior`);
}
break;
case "workflow_action":
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: fieldname,
timestamp: Date.now()
});
if (coworker.controller?.handleEvent) {
coworker.controller.handleEvent(run);
}
console.log(`✅ Workflow action: ${fieldname}`);
break;
default:
console.warn(`Unknown action: ${action}`);
}
};
return {
onChange: (fieldname, value) => {
const config = profile.onChange;
if (!config.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onChange_${fieldname}`]);
timersRef.current[`onChange_${fieldname}`] = setTimeout(
perform,
config.debounce
);
} else {
perform();
}
},
onBlur: (fieldname, value) => {
const config = profile.onBlur;
if (!config.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onBlur_${fieldname}`]);
timersRef.current[`onBlur_${fieldname}`] = setTimeout(
perform,
config.debounce
);
} else {
perform();
}
},
onButtonClick: (fieldname, value) => {
const config = profile.onButtonClick;
if (!config?.enabled) return;
const perform = () => executeAction(config.action, fieldname, value);
if (config.debounce > 0) {
clearTimeout(timersRef.current[`onButtonClick_${fieldname}`]);
timersRef.current[`onButtonClick_${fieldname}`] = setTimeout(
perform,
config.debounce
);
} else {
perform();
}
},
};
}, [run, behavior, profile]);Changes:
- Added
workflow_actioncase toexecuteAction() - Added
onButtonClickhandler that follows same pattern asonChange/onBlur
Now your Button component will work with handlers.onButtonClick(field.fieldname, field.label)
The error means profile.onButtonClick is undefined.
You need to add onButtonClick to your profile config:
fieldInteractionConfig: {
profiles: {
standard: {
onChange: { enabled: true, action: "write_draft", debounce: 300 },
onBlur: { enabled: true, action: "validate", debounce: 0 },
onButtonClick: { enabled: true, action: "workflow_action", debounce: 0 } // ← Add this
}
},
activeProfile: "standard"
}Where is your fieldInteractionConfig defined in your config file?
Good! Now the error should be gone.
Can you test clicking the button? What happens?
You should see in console:
✅ Workflow action: actionbuttonAnd then controller.handleEvent(run) should be called.
Does the button work now?
Perfect! Your config looks good.
Now test the button. You should see:
✅ Workflow action: actionbuttonDoes it work? What happens when you click the button?
If there's an error, what does the console say?
Here's the IIFE to debug:
(() => {
console.log("=== Debugging onButtonClick ===");
console.log("coworker._config:", coworker._config);
console.log("fieldInteractionConfig:", coworker._config?.fieldInteractionConfig);
console.log("profiles:", coworker._config?.fieldInteractionConfig?.profiles);
console.log("activeProfile:", coworker._config?.fieldInteractionConfig?.activeProfile);
const profile = coworker._config?.fieldInteractionConfig?.profiles?.[
coworker._config?.fieldInteractionConfig?.activeProfile
];
console.log("Resolved profile:", profile);
console.log("onButtonClick config:", profile?.onButtonClick);
})();Paste this in your browser console and share what it prints.
The config is perfect! onButtonClick exists.
The error must be happening during render, not in the handler definition.
Run this to check where the error occurs:
(() => {
console.log("=== Checking handlers object ===");
// Find the MainForm instance
const mainFormElement = document.querySelector('[class*="form"]');
console.log("MainForm element:", mainFormElement);
// Check if handlers are being created
const interactionConfig = coworker._config.fieldInteractionConfig;
const profile = interactionConfig.profiles[interactionConfig.activeProfile];
console.log("Profile being used:", profile);
console.log("Has onButtonClick?", 'onButtonClick' in profile);
console.log("onButtonClick value:", profile.onButtonClick);
})();Also, check your Button render function - is it being called before handlers are created?
Show me line 1808 of your coworker-config.js file.
Config is fine. The error is in the Button render function accessing handlers.onButtonClick before handlers exist.
Check your Button config - line 1808 of coworker-config.js:
Button: {
layoutOnly: true,
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers.onButtonClick) { // ← Line 1808 error here?
handlers.onButtonClick(field.fieldname, field.label);
}
};
// ...
}
}The issue: handlers might be undefined when Button renders.
Add safety check:
Button: {
layoutOnly: true,
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers?.onButtonClick) { // ← Add optional chaining
handlers.onButtonClick(field.fieldname, field.label);
} else {
console.warn("handlers.onButtonClick not available");
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label || "Button");
},
}Or check if the problem is that handlers isn't being passed to Button. How is Button being rendered in MainForm?
I see the problem!
You have TWO button implementations:
FieldButtoncomponent (lines 30-70) - Standalone component, doesn't usehandlersButtonin fieldTypes config - Useshandlers.onButtonClick
When you render the button in MainForm, which one gets used?
Looking at your MainForm render logic, you probably have:
// In MainForm fields.map()
if (fieldType.customComponent && fieldType.render) {
fieldType.render({ field, value: safeValue, handlers, run })
} else {
coworker.renderField({ field, value: safeValue, handlers, run })
}The issue: Your Button config has layoutOnly: true but not customComponent: true, so it's trying to go through coworker.renderField() which doesn't pass handlers correctly for layout fields.
Fix your Button config:
Button: {
layoutOnly: true,
customComponent: true, // ← Add this
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(field.fieldname, field.label);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label || "Button");
},
}Now it will use the custom render function with proper handlers passed in.
Yes, remove FieldButton.
You now have button handling through the config system with handlers.onButtonClick, so the standalone FieldButton component is redundant.
Remove:
const FieldButton = ({ field, run }) => {
// ... all this code
};Keep:
- Button config in
fieldTypeswithcustomComponent: true onButtonClickhandler in MainFormonButtonClickin profile config
Now all button behavior is config-driven through the universal handler system, not hardcoded components.
workflow_action is the action type that gets executed when a button is clicked.
In your executeAction switch statement:
case "workflow_action":
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: fieldname, // ← The button's fieldname (e.g., "actionbutton")
timestamp: Date.now()
});
if (coworker.controller?.handleEvent) {
coworker.controller.handleEvent(run);
}
console.log(`✅ Workflow action: ${fieldname}`);
break;This:
- Queues the button action as an event
- Calls
controller.handleEvent(run)to process it - Controller reads the event and executes the workflow transition
Now you need controller.handleEvent(run) to actually process the queued event.
Do you have controller.handleEvent() implemented? If not, that's the next step.
You're right - with your CURRENT code, controller.handleEvent() doesn't exist yet.
So for NOW, with what you have, change workflow_action to just call the existing save() method:
case "workflow_action":
// For now, just save
if (coworker.controller?.save) {
coworker.controller.save(run);
console.log(`✅ Button action: ${fieldname}`);
} else {
console.warn("controller.save not available");
}
break;This makes the button work immediately with your existing controller that has save() method.
Later when you implement controller.handleEvent(), you can switch back to the event queue pattern.
Yes, I see it. The workflow_action case calls coworker.controller.handleEvent(run), but you said you want to use CURRENT code only.
So the question is: Do you have coworker.controller.handleEvent() implemented or not?
If NO, then change workflow_action to:
case "workflow_action":
// Use existing save method
if (coworker.controller?.save) {
coworker.controller.save(run);
console.log(`✅ Button action: ${fieldname}`);
}
break;If YES, then keep it as is and test clicking the button - what happens?
"Button is not even reacting" means the click handler isn't firing.
Run this IIFE to check if the button is rendering with handlers:
(() => {
console.log("=== Button Debug ===");
// Find button element
const button = document.querySelector('button[type="button"]');
console.log("Button found:", button);
console.log("Button text:", button?.textContent);
// Check if Button config exists
const buttonConfig = coworker._config.fieldTypes.Button;
console.log("Button config:", buttonConfig);
console.log("Has customComponent?", buttonConfig?.customComponent);
console.log("Has render?", typeof buttonConfig?.render);
// Manually trigger to test
if (button) {
console.log("Click the button now and watch console...");
}
})();Click the button after running this and tell me:
- What logs appear?
- Does anything happen at all?
Good - button exists and config is correct. Now check if handlers are being passed to the render function:
(() => {
console.log("=== Check Button Render ===");
// Patch the Button render to see what it receives
const originalRender = coworker._config.fieldTypes.Button.render;
coworker._config.fieldTypes.Button.render = function(props) {
console.log("Button render called with:", props);
console.log("Has handlers?", !!props.handlers);
console.log("handlers.onButtonClick exists?", !!props.handlers?.onButtonClick);
console.log("field:", props.field);
return originalRender.call(this, props);
};
console.log("Patched Button render. Reload the form or navigate to trigger re-render.");
})();Then refresh the page or navigate to the form again and tell me what logs appear.
There it is! handlers is NOT being passed to Button render.
The problem is in your MainForm - check how you render Button fields:
Look for where you call the button render function. It's probably:
// Somewhere in MainForm
fieldType.render({ field, run }) // ❌ Missing handlers!It should be:
fieldType.render({ field, value: safeValue, handlers, run }) // ✅ Include handlersFind this in your MainForm code and add handlers to the props.
Show me the part of MainForm where you render fields (the .map() section).
You're right, let me look at your MainForm code above...
I don't see the field rendering loop in the code you pasted. The MainForm code you showed cuts off at the handlers definition.
Please paste the part of MainForm that renders the fields - the section that does:
fields.map(field => {
// ... rendering logic here
})That's where the bug is - Button isn't getting handlers passed to it.
I see renderField receives handlers, but that's for non-custom fields.
For Button with customComponent: true, it bypasses renderField and calls fieldType.render() directly.
Show me the part of MainForm where fields are mapped and rendered - it should look something like:
fields.map(field => {
// ...
if (fieldType.customComponent && fieldType.render) {
return fieldType.render({ field, value: safeValue, handlers, run }); // ← This line
} else {
return coworker.renderField({ field, value: safeValue, handlers, run });
}
})I need to see that specific section to find where handlers is missing.
You're right - let me search through what you provided.
Looking back, I don't see the fields rendering section in the MainForm code you pasted. The code cuts off after the handlers definition.
Can you paste the COMPLETE MainForm component? Starting from const MainForm = ({ run }) => { all the way to the closing };
The rendering logic with fields.map() should be at the bottom, but it's not in what you shared.
FOUND IT!
Line 236-241:
// Layout fields (full width)
if (fieldType.layoutOnly && fieldType.render) {
return React.createElement(
"div",
{ key: field.fieldname, style: { gridColumn: "1 / -1" } },
fieldType.render({ field, run }), // ❌ Missing handlers!
);
}Fix:
// Layout fields (full width)
if (fieldType.layoutOnly && fieldType.render) {
return React.createElement(
"div",
{ key: field.fieldname, style: { gridColumn: "1 / -1" } },
fieldType.render({ field, handlers, run }), // ✅ Add handlers
);
}That's it! Button is a layout field, so it goes through this path and wasn't getting handlers.
Yes, because other fields go through the ELSE path:
// Layout fields (full width)
if (fieldType.layoutOnly && fieldType.render) {
fieldType.render({ field, run }); // ❌ No handlers (Button goes here)
}
// Regular fields
// ...
fieldType.customComponent && fieldType.render
? fieldType.render({ field, value: safeValue, handlers, run }) // ✅ Has handlers
: coworker.renderField({ field, value: safeValue, handlers, run }); // ✅ Has handlersButton has layoutOnly: true, so it takes the FIRST path which doesn't pass handlers.
Other fields don't have layoutOnly: true, so they take the SECOND path which passes handlers.
Fix the first path and Button will work!
Good question!
layoutOnly: true means the field is not a data field - it doesn't store a value in the document, it's just for UI layout/actions.
Examples:
- Section Break - visual separator, no data
- Column Break - layout control, no data
- Button - triggers action, no data stored
Regular fields like Data, Int, Select store values in doc[fieldname], so they need different rendering logic (labels, validation errors, etc.)
But the problem is: Your code assumes layoutOnly fields don't need handlers, which is wrong for Button!
Two solutions:
- Remove
layoutOnly: truefrom Button - treat it like regular field - Pass
handlersto layout fields - they might need it (Button does!)
I recommend option 2 - just add handlers to the layoutOnly render call. Layout fields that don't need it will ignore it.
Yes! Two options:
Option 1: Remove layoutOnly: true from Button config
Button: {
// layoutOnly: true, // ← Remove this line
customComponent: true,
render: function ({ field, handlers, run }) {
// ...
}
}Then Button will go through the regular field path which already passes handlers.
Option 2: Fix MainForm to pass handlers to layoutOnly fields
if (fieldType.layoutOnly && fieldType.render) {
return React.createElement(
"div",
{ key: field.fieldname, style: { gridColumn: "1 / -1" } },
fieldType.render({ field, handlers, run }), // ← Add handlers here
);
}I recommend Option 2 - it's more correct because layout fields might need handlers (Button does!), and fixing MainForm once helps all future layout fields.
Try Option 2 first!
Two "Save" buttons rendering!
This means your Button field is rendering twice. Check your schema - do you have the button field defined twice?
Run this:
(() => {
const schema = coworker.currentRun?.target?.schema;
const buttonFields = schema?.fields.filter(f => f.fieldtype === "Button");
console.log("Button fields in schema:", buttonFields);
})();You probably have duplicate button entries in your Task schema.
Only 1 button in schema, but 2 rendering. This means the button is being rendered by BOTH paths in your MainForm!
Check if Button has BOTH layoutOnly: true AND customComponent: true:
(() => {
const buttonConfig = coworker._config.fieldTypes.Button;
console.log("Button config:", {
layoutOnly: buttonConfig.layoutOnly,
customComponent: buttonConfig.customComponent,
hasRender: !!buttonConfig.render
});
})();If both are true, it renders once in the layoutOnly path and once in the customComponent path.
The fix: Button should have EITHER layoutOnly: true OR customComponent: true, not both.
Which one do you want to keep?
You're right! I gave you this:
Button: {
layoutOnly: true,
customComponent: true, // ← Both are true!
render: function ({ field, handlers, run }) {
// ...
}
}That's the bug - it has BOTH flags, so it renders twice:
- Once in the
layoutOnlypath - Once in the
customComponentpath
Fix: Remove customComponent: true
Button: {
layoutOnly: true,
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(field.fieldname, field.label);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label || "Button");
},
}Now it only renders once via the layoutOnly path (which you already fixed to pass handlers).
You're right - it IS a custom component (has custom render function).
But in your code, customComponent flag is only checked for NON-layout fields:
// Layout fields path
if (fieldType.layoutOnly && fieldType.render) {
// Goes here - doesn't check customComponent
}
// Regular fields path
if (fieldType.customComponent && fieldType.render) {
// Only non-layout fields check this
}So for Button:
- Keep
layoutOnly: true(it's a layout element) - Remove
customComponent: true(flag is ignored for layout fields anyway) - Keep the
renderfunction
This prevents double rendering while still using your custom render function.
Perfect! Remove layoutOnly: true from Button config:
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(field.fieldname, field.label);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label || "Button");
},
}Now it will render once through the regular field path (which passes handlers) and the button should work!
Test it and tell me what happens when you click.
🎉 IT WORKS!
The button is now:
- ✅ Clicking successfully
- ✅ Handler executing (
✅ Button action: actionbutton) - ✅ Calling
controller.save(run) - ✅ Saving to database
- ✅ Update successful
Your button is fully functional through the config-driven system!
Summary of what we built:
✅ Button in config with custom render
✅ onButtonClick handler in MainForm
✅ onButtonClick profile config for behavior
✅ workflow_action case that calls controller.save()
✅ Button rendered via customComponent: true path with handlers passed correctly
The button now works exactly like other fields - fully config-driven, no hardcoded components!
Next: Make the button dynamic based on workflow state!
Right now your button always shows "Save". Let's make it show different buttons based on docstatus:
🎯 Dynamic Button Based on Workflow
Update your Button config:
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
// Determine button config based on docstatus
const getButtonConfig = (docstatus) => {
if (docstatus === 0) return { action: "submit", label: "Submit", className: window.CWStyles.button.success };
if (docstatus === 1) return { action: "cancel", label: "Cancel", className: window.CWStyles.button.danger };
if (docstatus === 2) return { action: "amend", label: "Amend", className: window.CWStyles.button.warning };
return { action: field.fieldname, label: field.label, className: window.CWStyles.button.primary };
};
const config = getButtonConfig(run.doc.docstatus);
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(config.action, config.label);
}
};
return React.createElement("button", {
type: "button",
className: config.className,
onClick: handleClick,
disabled: field.read_only,
}, config.label);
},
}Test: The button label should change when docstatus changes!
Try this and tell me if the button updates when you save/submit.
Here's the architecture we discussed for event emission and re-rendering:
🎯 Proposed Architecture: Event Emission Pattern
1. Button emits event → writes to run.doc.state
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
// Read current state to determine button
const getButtonConfig = (docstatus) => {
if (docstatus === 0) return { action: "submit", label: "Submit" };
if (docstatus === 1) return { action: "cancel", label: "Cancel" };
if (docstatus === 2) return { action: "amend", label: "Amend" };
return { action: field.fieldname, label: field.label };
};
const config = getButtonConfig(run.doc.docstatus);
const handleClick = () => {
// Queue event
if (!run.doc.state) run.doc.state = { events: [] };
run.doc.state.events.push({
type: "workflow_action",
action: config.action,
timestamp: Date.now()
});
// Process via controller
if (coworker.controller?.handleEvent) {
coworker.controller.handleEvent(run);
}
// Force button to re-render
forceUpdate();
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
}, config.label);
},
}2. Controller processes events
controller.handleEvent = function(run) {
const state = run.doc.state;
if (!state?.events?.length) return;
const events = [...state.events];
state.events = [];
for (const event of events) {
if (event.type === "workflow_action") {
this.executeWorkflowAction(run, event.action);
}
}
};
controller.executeWorkflowAction = function(run, action) {
switch(action) {
case "submit":
run.doc.docstatus = 1;
this.save(run);
break;
case "cancel":
run.doc.docstatus = 2;
this.save(run);
break;
case "amend":
run.doc.docstatus = 0;
this.save(run);
break;
}
};Key Points:
- Button has local state (
useReducerforforceUpdate) - Button reads
run.doc.docstatusto determine what to show - Button writes event to
run.doc.state.events[] - Button calls controller to process
- Button re-renders itself with
forceUpdate() - Controller mutates
run.doc.docstatus - Next render, button reads new docstatus → shows different label
This is the pattern where Button manages its own re-rendering, like Link field does!
Want to implement this?
You're right. Let me simplify.
The button should just re-read run.doc.docstatus on each render.
The question is: What triggers the button to re-render after docstatus changes?
Same question as before - when controller.save() changes run.doc.docstatus, what makes the button re-render to show the new label?
Yes! Let's implement this event-first architecture.
But first, the critical question:
In this architecture, MainForm writes events to run.doc.state.events[], then calls controller.handleEvent(run), which processes the events and updates run.doc.state.
After that, what triggers MainForm to re-render so the buttons update?
That's the missing piece. We need to answer this before implementing, because:
- Controller mutates
run.doc.state.docstatus = 1 - MainForm needs to re-render to call
getUIState(run)again getUIState()reads updated docstatus and returns different buttons- Buttons update
What triggers step 2 (MainForm re-render)?
Is it:
- A) MainForm has
useStatefor run? - B) Controller returns something that triggers re-render?
- C) Something else?
Tell me this and I'll give you the complete implementation.
Good question! Let me trace through exactly what happens:
Input Field (Works)
// User types "A"
onChange fires
↓
handlers.onChange(fieldname, "A")
↓
run.input[fieldname] = "A" // Mutation
↓
// Input shows "A" because...?Why does input show the new value?
Looking at your renderField code:
// Create state
const [state, setState] = React.useState(() => {
// ...
initialState[key] = this.evalTemplate(stateConfig[key], evalContext);
return initialState;
});
// Event handler
eventHandlers[eventName] = (e) => {
let newValue = e.target.value;
if (eventConfig.updateState) {
setState((prev) => ({
...prev,
[eventConfig.updateState]: newValue // ← setState triggers re-render
}));
}
};
// Use state value
if (state.localValue !== undefined) {
elementProps.value = state.localValue; // ← Reads from LOCAL state
}Input re-renders because it has LOCAL state (useState) and calls setState()!
Button (Doesn't Work)
// User clicks button
onClick fires
↓
handlers.onButtonClick(fieldname, value)
↓
run.doc.docstatus = 1 // Mutation (via controller)
↓
// Button needs to show different label based on new docstatus
↓
// ❓ What triggers button to re-render and re-read docstatus?Button has NO local state, so nothing triggers its re-render.
The Difference
| Aspect | Input Field | Button |
|---|---|---|
| Has local state | ✅ YES (useState) | ❌ NO |
| Calls setState | ✅ YES | ❌ NO |
| Re-renders on change | ✅ Automatic | ❌ Doesn't re-render |
| Reads from | Local state | run.doc.docstatus |
So the answer: Button needs either:
- Local state +
setState()(like input has) - OR MainForm re-renders (which re-renders button as child)
Which approach do you want?
YES! If Button has local state and calls setState() after the action, it will re-render and read the new docstatus, which will give it the new label.
Like this:
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
// Read docstatus to determine button config
const getButtonConfig = (docstatus) => {
if (docstatus === 0) return { action: "submit", label: "Submit" };
if (docstatus === 1) return { action: "cancel", label: "Cancel" };
if (docstatus === 2) return { action: "amend", label: "Amend" };
return { action: field.fieldname, label: field.label };
};
const config = getButtonConfig(run.doc.docstatus);
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(config.action, config.label);
}
// ✅ Force re-render after action completes
forceUpdate();
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
}, config.label);
},
}Flow:
- Button renders, reads
run.doc.docstatus = 0, shows "Submit" - User clicks →
handlers.onButtonClick("submit")→ controller changesrun.doc.docstatus = 1 forceUpdate()triggers button re-render- Button re-reads
run.doc.docstatus = 1, shows "Cancel"
Will this work for you?
Exactly right!
The button shouldn't hardcode the workflow states. It should read from the same workflow config the controller uses.
The button should call controller.getUIState(run) to get what to show:
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
// ✅ Get button config from controller (single source of truth)
const uiState = coworker.controller.getUIState(run);
const buttonConfig = uiState.buttons[0]; // Or find by field.fieldname
if (!buttonConfig) return null;
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(buttonConfig.action, buttonConfig.label);
}
forceUpdate();
};
return React.createElement("button", {
type: "button",
className: buttonConfig.className || window.CWStyles.button.primary,
onClick: handleClick,
disabled: buttonConfig.disabled,
}, buttonConfig.label);
},
}Now:
- Workflow config is in ONE place (controller)
- Button just renders what controller tells it
- No duplication
Does this solve it?
You're right. It's a mess.
Let me step back. What's the simplest way buttons should work in your system?
Looking at your current working code:
- You have a button in schema:
{fieldname: 'actionbutton', fieldtype: 'Button', label: 'Save'} - It renders
- It clicks
- It calls
controller.save() - It saves
That works. What's the actual problem you want to solve?
Is it just that the button always says "Save" and you want it to say different things based on state?
Excellent question. Let me explain the core difference:
🎯 The Core Issue
Link Field (Works Perfectly)
Link field state:
- dropdown open/closed → LOCAL to this field
- search text → LOCAL to this field
- selected value → LOCAL to this field
When user interacts:
- Opens dropdown → setState(open: true) → THIS field re-renders
- Types search → setState(searchText: "x") → THIS field re-renders
- Selects option → setState(value: "Acme") → THIS field re-renders
✅ Everything the field needs to display is SELF-CONTAINEDButton Field (The Problem)
Button label depends on:
- run.doc.docstatus (GLOBAL state, not local)
When user clicks button:
- Click → controller.save() → docstatus changes (0 → 1)
- Button needs to show different label
- But button doesn't know docstatus changed
- Button doesn't re-render
❌ Button display depends on EXTERNAL state (docstatus)The Architectural Struggle
Every other field you have:
- Reads its value from
doc[fieldname] - Has local state for UI concerns (dropdown open, search text)
- Manages its own display
- Re-renders via
setState()
Button is different:
- Doesn't have a "value" in
doc[fieldname] - Label comes from WORKFLOW STATE (docstatus)
- One button's action affects what ALL buttons show
- Can't use local state because it needs to react to external changes
Why Link with child.run() Works
Link field:
1. User opens dropdown → calls child.run(select)
2. Gets options → setState(options: [...])
3. Field re-renders with options
4. User selects → setState(value: "x")
5. Field re-renders with selected value
✅ All state changes go through setState
✅ Field controls when it re-rendersButton:
1. User clicks button → calls controller.save()
2. Controller changes docstatus
3. ❓ How does button know to re-render?
4. ❓ How does button get new label?The Real Question
For ALL your other fields: The field itself triggers re-render via setState()
For Button: Something ELSE changes state (controller), button needs to react
That's the struggle: Button needs to observe external state changes, not manage its own state.
Does this explain why Button is different?
AH! You're absolutely right!
Link field:
- Calls
run.child()to fetch external data (list of customers) - Gets the data back
- Calls
setOptions(data)to store it locally - Re-renders with the data
Button should do the SAME:
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [buttonConfig, setButtonConfig] = React.useState(null);
// Load button config based on current state
React.useEffect(() => {
const config = coworker.controller.getButtonConfig(run);
setButtonConfig(config);
}, [run.doc.docstatus]); // Re-run when docstatus changes
if (!buttonConfig) return null;
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(buttonConfig.action, buttonConfig.label);
}
// After action, reload button config
const newConfig = coworker.controller.getButtonConfig(run);
setButtonConfig(newConfig);
};
return React.createElement("button", {
type: "button",
className: buttonConfig.className || window.CWStyles.button.primary,
onClick: handleClick,
disabled: buttonConfig.disabled,
}, buttonConfig.label);
},
}Same pattern as Link:
- Link fetches options from external source (
run.child()) - Button fetches config from external source (
controller.getButtonConfig()) - Both store in local state
- Both re-render when state updates
Is this the pattern you want?
BRILLIANT!
You're saying: treat button actions as DATA, not hardcoded logic!
// Doctype: Workflow Action
{
name: "submit_task",
doctype: "Workflow Action",
from_state: 0,
to_state: 1,
label: "Submit",
action_type: "submit"
}Then Button fetches available actions:
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [actions, setActions] = React.useState([]);
const loadActions = async () => {
const childRun = await run.child({
operation: "select",
doctype: "Workflow Action",
query: {
filter: `from_state = ${run.doc.docstatus} AND applies_to = "${run.doc.doctype}"`
},
options: { render: false },
});
if (childRun.success) {
setActions(childRun.target.data);
}
};
React.useEffect(() => {
loadActions();
}, [run.doc.docstatus]);
if (actions.length === 0) return null;
const action = actions[0]; // Or map multiple buttons
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(action.action_type, action.label);
}
loadActions(); // Reload after action
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
}, action.label);
},
}Now workflow actions are DATA, not code!
Same pattern as Link - fetch external data, store in state, render.
Is this what you mean?
OK. From scratch. Simplest possible.
The Pattern
Every field (including Button):
- Renders based on current state
- User interacts → emits event
- Controller processes event
- Controller updates state
- Field re-renders with new state
Button Implementation
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const handleClick = () => {
if (handlers?.onButtonClick) {
handlers.onButtonClick(field.fieldname, field.label);
}
// Force re-render after controller processes
forceUpdate();
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
disabled: field.read_only,
}, field.label);
},
}That's it.
- Button renders with
field.label - User clicks → emits event via
handlers.onButtonClick - Controller processes (saves)
forceUpdate()makes button re-render- Button shows
field.labelagain
No workflow, no dynamic labels, no complexity.
If you want dynamic labels later, you add that. But this is the simplest working button.
Does this work?
Good question!
Looking at your schema:
{
"fieldname": "actionbutton",
"fieldtype": "Button",
"label": "Save"
}This is just a UI button in the form.
But your actual workflow actions (Submit, Cancel, Amend) - where do they come from?
Are they:
- Separate button fields in schema? (e.g.,
{fieldname: "submit_button", label: "Submit"}) - Generated by controller based on workflow state?
- Hardcoded in MainForm?
In Frappe, workflow buttons are generated automatically based on docstatus, not defined in schema.
What do you want?
AH! Now I understand completely!
Button is just like Link field - it selects from a list of available actions:
Button = Select from Available Actions
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [actions, setActions] = React.useState([]);
const [isOpen, setIsOpen] = React.useState(false);
// Load available actions (just like Link loads options)
const loadActions = async () => {
const childRun = await run.child({
operation: "select",
doctype: "Workflow Action", // Or wherever actions are stored
query: {
filter: `from_state = ${run.doc.docstatus} AND doctype = "${run.doc.doctype}"`
},
options: { render: false },
});
if (childRun.success) {
setActions(childRun.target.data || []);
setIsOpen(true);
}
};
const handleSelect = (action) => {
setIsOpen(false);
if (handlers.onButtonClick) {
handlers.onButtonClick(action.name, action.label);
}
// Reload actions after state changes
loadActions();
};
return React.createElement(
"div",
{ className: window.CWStyles.field.link },
// Trigger to show actions
React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: loadActions,
}, "Actions"),
// Dropdown of available actions
isOpen && actions.length > 0 && React.createElement(
"div",
{ className: window.CWStyles.field.linkDropdown },
actions.map((action) =>
React.createElement(
"div",
{
key: action.name,
className: window.CWStyles.field.linkOption,
onClick: () => handleSelect(action),
},
action.label
)
)
)
);
},
}Exactly like Link field:
- Opens dropdown
- Fetches available options (actions based on current state)
- User selects
- Emits event
- Re-fetches after state changes
Is this what you meant?
OH! So it's just styling!
Button is a Select field that:
- Looks like a button (styled differently)
- Shows available actions as options
- Single action displayed as button label
Button: {
customComponent: true,
render: function ({ field, handlers, run }) {
const [actions, setActions] = React.useState([]);
// Load available actions
React.useEffect(() => {
const loadActions = async () => {
const childRun = await run.child({
operation: "select",
doctype: "Workflow Action",
query: {
filter: `from_state = ${run.doc.docstatus}`
},
options: { render: false },
});
if (childRun.success) {
setActions(childRun.target.data || []);
}
};
loadActions();
}, [run.doc.docstatus]);
if (actions.length === 0) return null;
// Just show first action as button
const action = actions[0];
const handleClick = () => {
if (handlers.onButtonClick) {
handlers.onButtonClick(action.name, action.label);
}
};
return React.createElement("button", {
type: "button",
className: window.CWStyles.button.primary,
onClick: handleClick,
}, action.label); // ← Label from loaded action
},
}It's Link field logic, but renders as a button instead of dropdown.
Right?
🎯 UI/UX Evaluation: Button as Action Selector
❌ Problems with Single Button Showing First Action
| Issue | Problem | User Impact |
|---|---|---|
| Hidden Actions | If multiple actions available (Save, Submit), only first shows | User can't see Submit button exists |
| No Choice | User can't choose which action to take | Forces single workflow path |
| Confusing | Button label changes unexpectedly | "Where did Submit go?" |
| Discovery | No way to know other actions exist | Poor discoverability |
✅ Better Approaches
Option 1: Multiple Buttons (Industry Standard)
[Save] [Submit] [Cancel]Pros:
- ✅ Clear - all actions visible
- ✅ Familiar pattern (Gmail, Salesforce, every app)
- ✅ One click = one action
- ✅ Can style primary/secondary
Cons:
- Takes more horizontal space
Option 2: Dropdown Button (Power Users)
[Actions ▼]
├─ Save
├─ Submit
└─ CancelPros:
- ✅ Compact
- ✅ Shows all options
- ✅ Good for many actions (5+)
Cons:
- Requires 2 clicks
- Less discoverable
Option 3: Primary + Dropdown (Best of Both)
[Submit] [More ▼]
├─ Save Draft
├─ Cancel
└─ DeletePros:
- ✅ Primary action prominent (1 click)
- ✅ Secondary actions available (2 clicks)
- ✅ Clear hierarchy
- ✅ Used by GitHub, Google Drive
Cons:
- Slightly more complex to implement
🎯 Recommendation
Use Option 1 (Multiple Buttons) because:
- 2-3 buttons max (Draft → Save/Submit, Submitted → Cancel)
- Clear mental model - see all options
- Standard pattern - users expect it
- Simple implementation - map actions to buttons
// Render all available actions as buttons
actions.map(action =>
React.createElement("button", {
className: action.is_primary
? CWStyles.button.primary
: CWStyles.button.secondary,
onClick: () => handleAction(action)
}, action.label)
)Reserve Option 3 (Primary + Dropdown) for doctypes with 5+ actions.
📊 Comparison to Your Link Field
| Aspect | Link Field | Button Actions |
|---|---|---|
| Options count | 100s (customers) | 2-4 (actions) |
| Selection frequency | Once per form | Multiple times |
| Space concern | Must be compact | Space available |
| Pattern | Dropdown ✅ | Multiple buttons ✅ |
Verdict: Link uses dropdown because many options. Buttons should show all actions because few options.
What do you think?
You're absolutely right. I apologize.
The whole point is that available actions depend on current state:
docstatus = 0(Draft) → Show: Save, Submitdocstatus = 1(Submitted) → Show: Canceldocstatus = 2(Cancelled) → Show: Amend
That's why you query actions filtered by from_state:
filter: `from_state = ${run.doc.docstatus}`This returns ONLY the valid actions for current state.
So the pattern is:
- Query available actions based on current
docstatus - Render buttons for those actions only
- After action executes,
docstatuschanges - Re-query actions (different set appears)
The multiple buttons approach IS correct - but filtered by state.
Draft state: [Save] [Submit]
After submit: [Cancel]
After cancel: [Amend]
Is this what you want?
Comparison: Button with/without stored value
❌ Approach 1: Button stores no value
// Schema
{ fieldname: "action_btn", fieldtype: "Button", label: "Submit" }
// Document
{ name: "TASK-001", status: "Open", description: "..." }
// ← No action_btn field in doc
// To know what happened:
// - Check docstatus/status change
// - Check modification log
// - No direct record of button clickIssues:
- Can't track which button was clicked
- Can't audit user actions
- Can't replay events
- Button is "invisible" to data layer
✅ Approach 2: Button stores action taken
// Schema
{ fieldname: "last_action", fieldtype: "Data", label: "Last Action" }
// Document after Submit clicked
{
name: "TASK-001",
status: "Submitted",
last_action: "submit", // ← Stored
last_action_at: "2025-01-29T10:30:00Z"
}Benefits:
- ✅ Audit trail (who clicked what when)
- ✅ Event sourcing possible
- ✅ Can validate transitions
- ✅ Can show history: "Submitted by John at 10:30"
- ✅ Can prevent duplicate actions
- ✅ Button becomes "data field" like others
Verdict:
Store the action. It makes buttons observable, auditable, and consistent with other fields that store data.
You're right!
Don't use Data type for button actions. Create proper field types:
New Field Types for Actions
// Schema options:
// Option 1: Button stores last action
{
fieldname: "workflow_action",
fieldtype: "Button", // Stores value now
options: "Workflow Action" // Links to action doctype
}
// Option 2: New fieldtype for workflow
{
fieldname: "workflow_action",
fieldtype: "Workflow Button", // New type
options: "Workflow Action"
}
// Option 3: Action field (like Link but for actions)
{
fieldname: "last_action",
fieldtype: "Action", // New type
options: "Task" // Which doctype's actions
}Config
fieldTypes: {
"Button": {
element: "button",
storesValue: true, // ← NEW: Button now stores data
customComponent: true,
render: function({ field, value, handlers, run }) {
// value = last action taken
// Render based on available actions, store when clicked
}
},
// Or new type
"Action": {
element: "button",
storesValue: true,
linkTo: "Workflow Action", // Like Link field
customComponent: true,
render: function({ field, value, handlers, run }) {
// Behaves like Link but renders as button
}
}
}Benefits:
- Button/Action is proper fieldtype
- Stores value like other fields
- Clear semantic meaning
- Not misusing Data type
Which approach?
FSM → Action Field Mapping
FSM State Machine
States: Draft(0) → Submitted(1) → Cancelled(2)
Transitions:
- Draft → Submitted (submit)
- Draft → Draft (save)
- Submitted → Cancelled (cancel)
- Cancelled → Draft (amend)Schema Mapping Options
Option 1: Single Action Field (Current State)
{
fieldname: "current_action",
fieldtype: "Action",
label: "Action",
options: "Task", // Doctype to get actions for
// Value stores: "submit" | "cancel" | "amend" | "save"
}How it works:
- Field queries available actions for current
docstatus - Renders as button(s)
- On click, stores action taken
- Controller reads action, executes transition
Option 2: Action + State Fields
{
fieldname: "docstatus",
fieldtype: "Int",
hidden: 1
// Stores: 0 | 1 | 2
},
{
fieldname: "workflow_state",
fieldtype: "Select",
options: "Draft\nSubmitted\nCancelled",
read_only: 1
// Display only
},
{
fieldname: "action",
fieldtype: "Action",
options: "Task"
// Stores action that caused last transition
}
```
**FSM Mapping:**
```
docstatus = current state
action = edge/transition taken to reach this stateOption 3: Action History (Event Sourcing)
{
fieldname: "actions_taken",
fieldtype: "Table",
options: "Workflow Action Log"
// Stores all transitions: [{action: "submit", from: 0, to: 1, timestamp}]
},
{
fieldname: "current_action",
fieldtype: "Action",
options: "Task"
}
```
**FSM Mapping:**
```
actions_taken = history of all edges traversed
current_action = last edge taken
docstatus = current node/stateRecommended: Option 1 (Simplest)
Schema:
{
fieldname: "docstatus", // FSM state (node)
fieldtype: "Int",
hidden: 1
},
{
fieldname: "workflow_action", // FSM transition (edge)
fieldtype: "Action",
label: "Actions",
options: "Task"
}Mapping:
docstatus= current FSM state (0,1,2)workflow_action= last transition (submit, cancel, amend)- Available actions = valid edges from current state
Clean separation: State vs Transition
YES! Exactly right.
The Conceptual Problem
Fixed Schema:
{ fieldname: "action", fieldtype: "Action" } // ← ONE field definitionDynamic Reality:
// Draft state shows:
[Save] [Submit]
// Submitted state shows:
[Cancel]
// Cancelled state shows:
[Amend]One field definition → multiple UI elements based on state
Solution: JSON Field for Action Graph
// Schema (fixed)
{
fieldname: "workflow_state",
fieldtype: "JSON",
hidden: 1
}
// Document value (dynamic)
{
workflow_state: {
current: "Draft",
docstatus: 0,
available_actions: [
{ name: "save", label: "Save", to_state: 0 },
{ name: "submit", label: "Submit", to_state: 1 }
],
history: [
{ action: "save", from: 0, to: 0, timestamp: "..." }
]
}
}UI reads JSON and renders dynamically:
// Render component
const actions = doc.workflow_state.available_actions;
return actions.map(action =>
<button onClick={() => execute(action)}>{action.label}</button>
)Benefits
✅ One field in schema
✅ Multiple buttons in UI (state-dependent)
✅ Full state graph stored as data
✅ History tracking built-in
✅ Schema stays fixed while behavior is dynamic
Field stores the entire FSM state, UI renders current slice.
This is the right pattern!
Processing FSM → Available Actions
// Get available actions for current document state
coworker.getAvailableActions = function(doc, fsmDoc) {
const currentState = {
docstatus: doc.docstatus || 0,
dirty: doc._dirty ? 1 : 0,
validating: doc._state?.validating || "idle",
saving: doc._state?.saving || "idle",
submitting: doc._state?.submitting || "idle",
cancelling: doc._state?.cancelling || "idle"
};
const availableActions = [];
// Check each sequence (save, submit, cancel, validate)
for (const [actionName, sequence] of Object.entries(fsmDoc.sequences)) {
// Get the first step's transition requirements
const firstStep = sequence.steps[0];
const transitionName = firstStep.transitions[0]?.state + "_to_" + firstStep.transitions[0]?.value;
// Find the rule for this transition
const stateName = firstStep.transitions[0]?.state;
const rules = fsmDoc.rules[stateName];
if (!rules) continue;
// Check if current state meets requirements
const requirements = Object.values(rules)[0]?.requires;
if (!requirements) continue;
const canExecute = Object.entries(requirements).every(([key, value]) => {
return currentState[key] === value;
});
if (canExecute) {
availableActions.push({
name: actionName,
label: actionName.charAt(0).toUpperCase() + actionName.slice(1),
sequence: sequence
});
}
}
return availableActions;
};
// Execute action through FSM
coworker.executeAction = async function(doc, actionName, fsmDoc) {
const sequence = fsmDoc.sequences[actionName];
if (!sequence) return { success: false, error: "Unknown action" };
for (const step of sequence.steps) {
// Apply transitions
if (step.transitions) {
for (const t of step.transitions) {
if (!doc._state) doc._state = {};
doc._state[t.state] = t.value;
}
}
// Execute command
if (step.execute) {
try {
const result = await this.executeCommand(doc, step.execute);
if (result.success && step.onSuccess) {
for (const t of step.onSuccess) {
if (t.stop) break;
if (!doc._state) doc._state = {};
doc._state[t.state] = t.value;
}
} else if (!result.success && step.onFailure) {
for (const t of step.onFailure) {
if (t.stop) return { success: false, error: result.error };
if (!doc._state) doc._state = {};
doc._state[t.state] = t.value;
}
}
} catch (error) {
return { success: false, error: error.message };
}
}
}
return { success: true };
};Usage:
// In Button render
const fsmDoc = await run.child({
operation: "takeone",
doctype: "State Machine",
query: { name: "Document_FSM" }
});
const actions = coworker.getAvailableActions(run.doc, fsmDoc.target.data);
// Render buttons
actions.map(action =>
<button onClick={() => coworker.executeAction(run.doc, action.name, fsmDoc.target.data)}>
{action.label}
</button>
)FSM drives everything - schema stays fixed, behavior is data.
No, it's not optimal.
Problems:
- Fetches FSM doc on every render - expensive
- Loops through all sequences to check availability - O(n)
- Nested loops checking requirements - O(n*m)
- No caching - recalculates same thing repeatedly
- Sequential step execution - could be async/parallel
- FSM doc structure is complex - hard to query efficiently
Better approach:
// Pre-compute action availability matrix (once per FSM)
const ACTION_MATRIX = {
"save": { docstatus: [0], dirty: [1], validating: ["valid"], ... },
"submit": { docstatus: [0], dirty: [0], validating: ["valid"], ... },
"cancel": { docstatus: [1], ... }
};
// O(1) lookup
coworker.getAvailableActions = function(doc) {
return Object.entries(ACTION_MATRIX)
.filter(([action, reqs]) =>
Object.entries(reqs).every(([key, allowed]) =>
allowed.includes(doc[key])
)
)
.map(([name]) => ({ name, label: capitalize(name) }));
};Or even simpler - flatten FSM into function:
coworker.getAvailableActions = function(doc) {
const actions = [];
if (doc.docstatus === 0 && doc._dirty && doc._validating === "valid")
actions.push("save");
if (doc.docstatus === 0 && !doc._dirty && doc._validating === "valid")
actions.push("submit");
if (doc.docstatus === 1)
actions.push("cancel");
return actions;
};FSM is over-engineered for this use case. Do you need the full FSM complexity?
Ah! Now I understand.
FSM is the universal execution engine for controller operations, not just UI buttons.
Correct Architecture
// FSM processes ANY operation chain
controller.execute = async function(run, actionName) {
// 1. Load FSM for this doctype
const fsm = await this.getFSM(run.doc.doctype);
// 2. Check if action is available
const available = this.checkAvailable(run.doc, actionName, fsm);
if (!available) return { success: false, error: "Action not allowed" };
// 3. Execute sequence through FSM
return await this.executeFSM(run, actionName, fsm);
};
// UI just triggers actions
Button: {
render: ({ run }) => {
const actions = controller.getAvailableActions(run);
return actions.map(a =>
<button onClick={() => controller.execute(run, a.name)}>
{a.label}
</button>
);
}
}
// API calls also go through FSM
api.post('/submit', async (req) => {
await controller.execute(run, "submit");
});
// Scheduled jobs go through FSM
cron.daily(() => {
await controller.execute(run, "auto_archive");
});FSM is the execution layer. UI, API, cron all call it.
So the question becomes: How to efficiently query FSM for available actions without parsing complex JSON every time?
Cache? Index? Pre-compile?
No, the architecture is NOT optimal.
Issues:
1. Tight Coupling
// FSM knows about specific commands
step.execute = "validate" // ← FSM hardcoded to know "validate" exists
step.execute = "dbSave" // ← FSM hardcoded to know "dbSave" existsProblem: FSM should be generic, not know about domain commands.
2. Mixed Concerns
sequences: {
save: {
steps: [
{ transitions: [...] }, // ← State management
{ execute: "validate" }, // ← Business logic
{ execute: "dbSave" }, // ← Infrastructure
]
}
}Problem: FSM mixes state transitions with business operations.
3. Complex State Model
currentState = {
validating: "idle",
saving: "idle",
submitting: "idle",
cancelling: "idle"
}Problem: Parallel states (validating + saving + submitting) when operations are sequential. Over-complex for the actual use case.
4. No Separation: State vs Event
rules: {
saving: {
idle_to_saving: { // ← State transition name hardcoded
requires: {...}
}
}
}Problem: Transition names baked into structure. Should be: state + event → new state.
Optimal Architecture:
// FSM: Pure state machine (no business logic)
{
states: ["Draft", "Submitted", "Cancelled"],
transitions: [
{ from: "Draft", event: "submit", to: "Submitted", guard: "canSubmit" },
{ from: "Submitted", event: "cancel", to: "Cancelled" }
]
}
// Controller: Business logic (uses FSM)
controller.execute = async function(run, event) {
// Check FSM allows transition
const canTransition = fsm.can(run.doc.state, event);
if (!canTransition) return false;
// Execute business logic
await this.executeCommand(event, run);
// Update state via FSM
run.doc.state = fsm.transition(run.doc.state, event);
};Separation:
- FSM = state transitions only
- Controller = business logic
- Commands = reusable operations
Current FSM tries to be everything. Make it do one thing well.
True, but at what cost?
Your FSM allows ANY actions, but:
❌ Problems:
- Every new action = update FSM document
- Add new sequence
- Add new rules
- Add new state transitions
- Complex nested JSON editing
- FSM becomes bottleneck
- All logic lives in one document
- Hard to version
- Hard to test individual actions
- Can't reuse sequences across doctypes
- Validation at runtime only
- No compile-time checks
- Typo in
step.execute = "valdiate"→ runtime error - No IDE support
- Hard to extend
- Want conditional logic? Add more JSON nesting
- Want loops? Add more JSON structure
- Reinventing programming language in JSON
✅ Optimal: Code + FSM Hybrid
// FSM: Just state transitions (data)
{
states: ["Draft", "Submitted", "Cancelled"],
transitions: [
{ from: "Draft", on: "submit", to: "Submitted" },
{ from: "Submitted", on: "cancel", to: "Cancelled" }
]
}
// Actions: Code (extensible)
controller.actions = {
submit: async (run) => {
await this.validate(run);
await this.save(run);
run.doc.docstatus = 1;
},
cancel: async (run) => {
run.doc.docstatus = 2;
},
custom_approval: async (run) => { // ← Easy to add
await this.notify(run.doc.approver);
if (await this.checkApproval()) {
run.doc.approved = true;
}
}
};
// Execute
controller.execute = async (run, action) => {
if (!fsm.canTransition(run.doc.state, action)) return false;
await this.actions[action](run);
run.doc.state = fsm.transition(run.doc.state, action);
};Answer: Your FSM allows ANY actions, but loses:
- ✅ Simplicity
- ✅ Maintainability
- ✅ Type safety
- ✅ Testability
- ✅ Developer experience
Is "allows ANY actions" worth these tradeoffs?
Or: FSM for states, Code for actions?
You're right. If Actions and States are foundational, they should be schema/data.
Then the question becomes: Is the current FSM schema optimal for that goal?
What makes schema optimal for foundational concepts:
✅ Should have:
- Queryable - Can filter/search actions and states
- Composable - Can reference across doctypes
- Versioned - Can track changes
- Validated - Schema enforces structure
- UI-editable - Non-developers can modify
- Reusable - One action used in multiple flows
Current FSM:
- ❌ Single monolithic document
- ❌ Nested JSON hard to query
- ❌ Can't reference actions across doctypes
- ❌ Hard to validate structure
- ❌ Complex for UI editing
Optimal Schema: Normalize the FSM
Instead of one FSM document, break into proper doctypes:
// Doctype: State
{
name: "Draft",
doctype: "Workflow State",
value: 0,
is_initial: 1,
color: "orange"
}
// Doctype: Action
{
name: "submit",
doctype: "Workflow Action",
label: "Submit",
command: "dbSubmit",
validation_required: 1
}
// Doctype: Transition
{
doctype: "Workflow Transition",
from_state: "Draft",
to_state: "Submitted",
action: "submit",
conditions: [
{ field: "docstatus", operator: "=", value: 0 },
{ field: "dirty", operator: "=", value: 0 }
]
}
// Doctype: Workflow
{
name: "Document Workflow",
doctype: "Workflow",
applies_to: "Task",
states: [Link to State],
transitions: [Link to Transition]
}Benefits:
- ✅ Each concept is queryable:
SELECT * FROM Workflow Action WHERE applies_to = 'Task' - ✅ Reusable: "submit" action used across multiple workflows
- ✅ UI can have forms for each doctype
- ✅ Can version, track changes
- ✅ Proper relations between concepts
vs current monolithic JSON blob.
This is what "foundational schema" means - normalized, relational, queryable.
Is this the direction you want?
It's not bad - it's good FSM modeling!
Let me re-evaluate:
✅ What's Good:
- Explicit state transitions - clear which states can go where
- Error states - handles
submittingErrorsproperly - Self-contained - all info about "submitting" in one place
- Validation-ready - can validate
idle → submittingis allowed - Complete state graph - covers all possible transitions
Why I was wrong:
I said it was "over-complex" because I was thinking:
- "Just use docstatus 0/1/2"
- "Don't need submitting/submitted/idle substates"
But you DO need substates because:
submitting = "submitting"→ show loading spinnersubmitting = "submitted"→ show success messagesubmitting = "submittingErrors"→ show error, allow retrysubmitting = "idle"→ ready for next action
This is proper async state modeling.
The real question:
Is this structure optimal for storage/query, or should it be:
Option A (Current):
submitting: {
options: ["idle", "submitting", "submitted", "submittingErrors"],
transitions: { idle: ["submitting"], ... }
}Option B (Flat):
states: [
{ name: "submitting.idle", transitions: ["submitting.submitting"] },
{ name: "submitting.submitting", transitions: ["submitting.submitted", "submitting.submittingErrors"] }
]Option C (Normalized):
// State table
{ id: 1, name: "submitting.idle" }
{ id: 2, name: "submitting.submitting" }
// Transition table
{ from_state: 1, to_state: 2 }Your current structure (Option A) is fine for:
- Single FSM document
- Hierarchical state grouping
- Human-readable
Is there a specific problem you're facing with it?
I need you to complete your question - "please reconfirm the" what?
Are you asking me to reconfirm:
- That your FSM structure is good?
- The architecture approach?
- Something else?
Please clarify what you want me to reconfirm.
Reconfirming: JSON Field → Action Field Generation
Yes, this is the right approach:
Schema (Fixed)
{
fieldname: "workflow_state",
fieldtype: "JSON",
hidden: 1
}Document Value (Dynamic)
doc.workflow_state = {
current: {
docstatus: 0,
submitting: "idle",
saving: "idle",
validating: "valid"
},
available_actions: [
{ name: "save", label: "Save" },
{ name: "submit", label: "Submit" }
]
}UI Renders from JSON
Button: {
render: ({ field, handlers, run }) => {
const [actions, setActions] = React.useState([]);
React.useEffect(() => {
// Compute available actions from FSM + current state
const fsm = getFSM(run.doc.doctype);
const available = computeAvailableActions(run.doc.workflow_state, fsm);
setActions(available);
}, [run.doc.workflow_state]);
return actions.map(action =>
React.createElement("button", {
onClick: () => handlers.onButtonClick(action.name),
}, action.label)
);
}
}✅ Confirmed Benefits:
- One field in schema → Multiple buttons in UI
- State-driven → Actions computed from current state
- Dynamic → Button set changes as state changes
- FSM-backed → All transitions validated
- No hardcoding → Workflow is data
This architecture is solid.
What part needs clarification?